firebase • firestore • javascript • security
Rate Limiting With Firestore Security Rules
by Steve Marx on
The New High Score game from my last post has a flaw! Although the security rules require that the score only increase by 1 at a time, nothing prevents a clever hacker from writing a script to click many times per second.
In this post, I’ll fix that flaw by using server-side timestamps to disallow more than one update per second.
The data model
I’ve adapted the previous data model to add a lastUpdate
field:
<database>
└─games
├─user1
| ├─score: 123
│ └─lastUpdated: 2021/01/01 1:23:04pm
├─user2
| ├─score: 456
│ └─lastUpdated: 2020/12/25 4:56:07am
...
When a new update comes in, security rules can validate that it’s been at least one second since the value stored in the lastUpdate
field.
Working with time
The Firestore security rules reference documentation contains quite a few helpful tidbits. Particularly useful for us is request.time
. This is a server-side timestamp, indicating at what time the request was received by the server.
This is a piece of the puzzle. With a little help from duration.value
I can now write a security rule like this:
allow update: if request.time >=
resource.data.lastUpdate + duration.value(1, "s");
But how does lastUpdate
get updated in the first place?
Client-provided values, verified on the server
Firestore security rules can’t change data themselves. This leaves us with a conundrum. The only way to obtain the lastUpdate
value is to get it from the client, but the client can’t be trusted. Someone trying to get around the rate limit could simply change the source code of the page (or write their own update entirely) to lie about the lastUpdate
time.
The solution is to have the client provide the data but have the server verify that it’s correct. The client will send a new lastUpdate
value with each update, but the security rules will require that this value matches the value in request.time
.
This presents another little wrinkle. How does the client predict the correct server-side timestamp? It would certainly be possible to use the client’s clock and fuzzy matching on the server, but Firestore provides a better mechanism for this. There are special sentinel values, which are filled in on the server. Here I’m using two of them:
doc.set({
score: firebase.firestore.FieldValue.increment(1),
lastUpdate: firebase.firestore.FieldValue.serverTimestamp(),
}, { merge: true });
FieldValue.increment(amount)
automatically increments a numeric field. If the field is absent or non-numeric, it’s just set to the increment amount.FieldValue.serverTimestamp()
is filled in on the server with the request time. In other words, it will always matchrequest.time
.
Now the security rules can check that request.data.lastUpdate == request.time
, ensuring that this value is correctly filled in for each request.
Increment and { merge: true }
Readers who are paying far too much attention may have noticed that I added a second argument to the call to doc.set()
. The type of this parameter is SetOptions, and it changes the way Firestore handles the set operation.
In this case, I’m passing { merge: true }
. This type of set operation will only update the specified fields, rather than replacing the whole document. This is necessary to get FieldValue.increment
to work. (If I’m replacing the whole document, then what exactly is being incremented?)
set(..., { merge: true })
works a lot like calling update()
, but the latter fails if the document doesn’t already exist.
Putting it all together
Here’s the updated firebase.rules
that makes use of the new lastUpdate
field:
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
match /games/{document=**} {
allow read;
allow create: if request.resource.id == request.auth.uid
&& request.resource.data.keys().hasOnly(["score", "lastUpdate"])
&& request.resource.data.lastUpdate == request.time
&& request.resource.data.score == 1;
allow update: if request.resource.id == request.auth.uid
&& request.resource.data.keys().hasOnly(["score", "lastUpdate"])
&& request.resource.data.lastUpdate == request.time
&& request.time >= resource.data.lastUpdate + duration.value(1, "s")
&& request.resource.data.score == resource.data.score + 1;
}
}
}
I’ve updated the live New High Score game to use this rate limiting.
Summary
- Via
request.time
, Firestore security rules can access the time a request was received by the server. - Firestore server-side computed values in the
FieldValue
namespace. - Clients can’t be trusted, which is why security rules need to validate all data.