47

I launched my first open repository project, EphChat, and people promptly started flooding it with requests.

Does Firebase have a way to rate limit requests in the security rules? I assume there's a way to do it using the time of the request and the time of previously written data, but can't find anything in the documentation about how I would do this.

The current security rules are as follows.

{
    "rules": {
      "rooms": {
        "$RoomId": {
          "connections": {
              ".read": true,
              ".write": "auth.username == newData.child('FBUserId').val()"
          },
          "messages": {
            "$any": {
            ".write": "!newData.exists() || root.child('rooms').child(newData.child('RoomId').val()).child('connections').hasChild(newData.child('FBUserId').val())",
            ".validate": "newData.hasChildren(['RoomId','FBUserId','userName','userId','message']) && newData.child('message').val().length >= 1",
            ".read": "root.child('rooms').child(data.child('RoomId').val()).child('connections').hasChild(data.child('FBUserId').val())"
            }
          },
          "poll": {
            ".write": "auth.username == newData.child('FBUserId').val()",
            ".read": true
          }
        }
      }
    }
}

I would want to rate-limit writes (and reads?) to the db for the entire Rooms object, so only 1 request can be made per second (for example).

Tomer Shetah
  • 8,413
  • 7
  • 27
  • 35
Brian Mayer
  • 989
  • 3
  • 13
  • 23

4 Answers4

51

The trick is to keep an audit of the last time a user posted a message. Then you can enforce the time each message is posted based on the audit value:

{
  "rules": {
          // this stores the last message I sent so I can throttle them by timestamp
      "last_message": {
        "$user": {
          // timestamp can't be deleted or I could just recreate it to bypass our throttle
          ".write": "newData.exists() && auth.uid === $user",
          // the new value must be at least 5000 milliseconds after the last (no more than one message every five seconds)
          // the new value must be before now (it will be since `now` is when it reaches the server unless I try to cheat)
          ".validate": "newData.isNumber() && newData.val() === now && (!data.exists() || newData.val() > data.val()+5000)"
        }
      },

      "messages": {
        "$message_id": {
          // message must have a timestamp attribute and a sender attribute
          ".write": "newData.hasChildren(['timestamp', 'sender', 'message'])",
          "sender": {
            ".validate": "newData.val() === auth.uid"
          },
          "timestamp": {
            // in order to write a message, I must first make an entry in timestamp_index
            // additionally, that message must be within 500ms of now, which means I can't
            // just re-use the same one over and over, thus, we've effectively required messages
            // to be 5 seconds apart
            ".validate": "newData.val() >= now - 500 && newData.val() === data.parent().parent().parent().child('last_message/'+auth.uid).val()"
          },
          "message": {
            ".validate": "newData.isString() && newData.val().length < 500" 
          },
          "$other": {
            ".validate": false 
          }
        }
      } 
  }
}

See it in action in this fiddle. Here's the gist of what's in the fiddle:

var fb = new Firebase(URL);
var userId; // log in and store user.uid here

// run our create routine
createRecord(data, function (recordId, timestamp) {
   console.log('created record ' + recordId + ' at time ' + new Date(timestamp));
});

// updates the last_message/ path and returns the current timestamp
function getTimestamp(next) {
    var ref = fb.child('last_message/' + userId);
    ref.set(Firebase.ServerValue.TIMESTAMP, function (err) {
        if (err) { console.error(err); }
        else {
            ref.once('value', function (snap) {
                next(snap.val());
            });
        }
    });
}

function createRecord(data, next) {
    getTimestamp(function (timestamp) {
        // add the new timestamp to the record data
        var data = {
          sender: userId,
          timestamp: timestamp,
          message: 'hello world'
        };

        var ref = fb.child('messages').push(data, function (err) {
            if (err) { console.error(err); }
            else {
               next(ref.name(), timestamp);
            }
        });
    })
}
Kato
  • 40,352
  • 6
  • 119
  • 149
  • 4
    This is great for a benevolent client. What about a hacking attack? Someone could just not post to the last_message reference. Then, they could just hammer away at your reference and fill it with requests over and over. Is there any way to provide rate limiting to avoid this? – Justin Noel Sep 15 '14 at 15:04
  • 4
    Actually, I think I retract this. It seems Kato has this covered. You MUST post to last_message or the write to "messages" will fail. "last_message" prevents cramming by requiring that the last message be no less than 5 seconds ago. Very elegant – Justin Noel Sep 15 '14 at 15:40
  • 1
    Yep, not the simplest rules to wrap the brain around, but they do get the job done! – Kato Sep 15 '14 at 19:03
  • Is there a way to make this work for an unauthenticated user? My guess is that it isn't important *who* does it, so a guid could be the key rather than `$user` in the first table. Not sure if the current implementation does that, but you might also have to limit resending, and possibly put both a and min window of time for each of those messages. Does that sound right to you? I guess the problem with that is, though, that you could clump messages together to get past the rate limiting... – Merlyn Morgan-Graham Aug 04 '15 at 06:22
  • 3
    You're overthinking this and making it way too complicated for a one-off your'e not going to run into. If you need bank-level security on your app, write a server side process and have it push the messages, add any level of throttling you'd like. Or save yourself some time and get your app published and in use. – Kato Aug 04 '15 at 19:58
  • @Kato - I imported this code and ran it on my own firebase. It seems that at line 15, ref.once() never calls its callback, thus my firebase never has any messages, just "last_message"s. – David May 07 '16 at 20:58
  • This has a flaw: it lets you submit unlimited entries for a 500ms window every 5 seconds. You can submit multiple entries at once (as a large object) in that 500ms window. – Victor Dec 04 '16 at 02:53
  • If you think you can break it, you should definitely submit some code that does so. Note that the audit records have to be within 500ms, and each record has to be 5s apart, and the record must match the audit value (they can't reconcile those numbers). Also note that `newData.val() === now` probably works these days (did not 2 years ago when this was posted) – Kato Dec 05 '16 at 17:46
  • When I read the timestamp after writing to user database, it takes 2 seconds, not 500 ms, is there any way to speed it up? rather than reading the value once more – Ananthu Subramanian Feb 09 '19 at 14:20
  • Please can you tell me what your `next()` function does? – MarkP Aug 19 '19 at 15:45
3

I don't have enough reputations to write in the comment, but I agree to Victor's comment. If you insert the fb.child('messages').push(...) into a loop (i.e. for (let i = 0; i < 100; i++) {...} ) then it would successfully push 60-80 meessages ( in that 500ms window frame.

Inspired by Kato's solution, I propose a modification to the rules as follow:

rules: {
  users: {
    "$uid": {
      "timestamp": { // similar to Kato's answer
        ".write": "auth.uid === $uid && newData.exists()"
        ,".read": "auth.uid === $uid"
        ,".validate": "newData.hasChildren(['time', 'key'])"
        ,"time": {
          ".validate": "newData.isNumber() && newData.val() === now && (!data.exists() || newData.val() > data.val() + 1000)"
        }
        ,"key": {

        }
      }
      ,"messages": {
        "$key": { /// this key has to be the same is the key in timestamp (checked by .validate)
           ".write": "auth.uid === $uid && !data.exists()" ///only 'create' allow
           ,".validate": "newData.hasChildren(['message']) && $key === root.child('/users/' + $uid + '/timestamp/key').val()"
           ,"message": { ".validate": "newData.isString()" }
           /// ...and any other datas such as 'time', 'to'....
        }
      }
    }
  }
}

The .js code is quite similar to Kato's solution, except that the getTimestamp would return {time: number, key: string} to the next callback. Then we would just have to ref.update({[key]: data})

This solution avoids the 500ms time-window, we don't have to worry that the client must be fast enough to push the message within 500ms. If multiple write requests are sent (spamming), they can only write into 1 single key in the messages. Optionally, the create-only rule in messages prevents that from happening.

ChiNhan
  • 109
  • 3
2

I liked Kato's answer but it doesn't take into account a malicious user flooding the chat between the 500ms window simply using a for loop. I propose this variant that eliminates the possibility:

{
  "rules": {
    "users": {
      "$uid": {
        "rateLimit": {
          "lastMessage": {
            // newData.exists() ensures newData is not null and prevents deleting node
            // and $uid === auth.uid ensures the user writing this child node is the owner
            ".write": "newData.exists() && $uid === auth.uid",
            // newData.val() === now ensures the value written is the current timestamp
            // to avoid tricking the rules writting false values
            // and (!data.exists() || newData.val() > data.val() + 5000)
            // ensures no data exists currently in the node. Otherwise it checks if the
            // data that will overwrite the node is a value higher than the current timestamp
            // plus the value that will rate limit our messages expressed in milliseconds.
            // In this case a value of 5000 means that we can only send a message if
            // the last message we sent was more than 5 seconds ago
            ".validate": "newData.val() === now && (!data.exists() || newData.val() > data.val() + 5000)"
          }
        }
      }
    },
    "messages": {
      "$messageId": {
        // This rule ensures that we write lastMessage node avoiding just sending the message without
        // registering a new timestamp
        ".write": "newData.parent().parent().child('users').child(auth.uid).child('rateLimit').child('lastMessage').val() === now",
        // This rule ensures that we have all the required message fields
        ".validate": "newData.hasChildren(['timestamp', 'uid', 'message'])",
        "uid": {
          // This rule ensures that the value written is the id of the message sender
          ".validate": "newData.val() === auth.uid"
        },
        "timestamp": {
          // This rule ensures that the message timestamp can't be modified
          ".write": "!data.exists()",
          // This rule ensures that the value written is the current timestamp
          ".validate": "newData.val() === now"
        },
        "message": {
          // This rule ensures that the value written is a string
          ".validate": "newData.isString()"
        },
        "$other": {
          // This rule ensures that we cant write other fields in the message other than
         // the explicitly declared above
         ".validate": false
        }
      }
    }
  }
}

The code implementation uses atomic writes across multiple locations. If one validation fails, the operation doesn't complete and no operation is done in the database

function sendMessage(message) {
    const database = firebase.database();

    const pushId = database.ref().child("messages").push().key;
    const userId = firebase.auth().currentUser.uid;
    const timestampPlaceholder = firebase.database.ServerValue.TIMESTAMP;

    let updates = {};
    updates["messages/" + pushId] = {
      uid: userId,
      timestamp: timestampPlaceholder,
      message: message,
    };
    updates[`users/${userId}/rateLimit/lastMessage`] = timestampPlaceholder;

    database.ref().update(updates);
  }
Tomer Shetah
  • 8,413
  • 7
  • 27
  • 35
0

The existing answers use two database updates: (1) mark a timestamp, and (2) attach the marked timestamp to the actual write. Kato's answer requires 500ms time-window, while ChiNhan's requires remembering the next key.

There is a simpler way to do it in a single database update. The idea is to write multiple values to the database at once using the update() method. The security rules validates the written values so that the write does not exceed the quota. The quota is defined as a pair of values: quotaTimestamp and postCount. The postCount is the number of posts written within 1 minute of the quotaTimestamp. The security rules simply rejects the next write if the postCount exceeds a certain value. The postCount is reset when the quotaTimestamp is staler then 1 minute.

Here is how to post a new message:

function postMessage(user, message) {
  const now = Date.now() + serverTimeOffset;
  if (!user.quotaTimestamp || user.quotaTimestamp + 60 * 1000 < now) {
    // Resets the quota when 1 minute has elapsed since the quotaTimestamp.
    user.quotaTimestamp = database.ServerValue.TIMESTAMP;
    user.postCount = 0;
  }
  user.postCount++;

  const values = {};
  const messageId = // generate unique id
  values[`users/${user.uid}/quotaTimestamp`] = user.quotaTimestamp;
  values[`users/${user.uid}/postCount`] = user.postCount;
  values[`messages/${messageId}`] = {
    sender: ...,
    message: ...,
    ...
  };
  return this.db.database.ref().update(values);
}

The security rules to rate limit to at most 5 posts per minute:

{
  "rules": {
    "users": {
      "$uid": {
        ".read": "$uid === auth.uid",
        ".write": "$uid === auth.uid && newData.child('postCount').val() <= 5",
        "quotaTimestamp": {
          // Only allow updating quotaTimestamp if it's staler than 1 minute.
          ".validate": "
            newData.isNumber()
            && (newData.val() === now
              ? (data.val() + 60 * 1000 < now)
              : (data.val() == newData.val()))"
        },
        "postCount": {
          // Only allow postCount to be incremented by 1
          // or reset to 1 when the quotaTimestamp is being refreshed.
          ".validate": "
            newData.isNumber()
            && (data.exists()
              ? (data.val() + 1 === newData.val()
                || (newData.val() === 1
                    && newData.parent().child('quotaTimestamp').val() === now))
              : (newData.val() === 1))"
        },
        "$other": { ".validate": false }
      }
    },

    "messages": {
      ...
    }
  }
}

Note: the serverTimeOffset should be maintained to avoid clock skew.

Tomer Shetah
  • 8,413
  • 7
  • 27
  • 35