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);
            }
        });
    })
}

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.