I asked GPT-4 to help me write a set of Firebase Realtime Database Security Rules. As it turns out, GPT-4 has quite a rudimentary understanding of Firebase Realtime Database Security Rules.
Its rudimentary knowledge means that:
- It sometimes writes insecure rules.
- It sometimes writes validation rules in
.write
instead of.validate
. - It sometimes forgets that validation rules are not executed when the data is being deleted or set to
null
.
However, it can also handle many edge cases that I didn't think of. It takes several iterations of feedback to finally get the correct rules.
In conclusion, security rules generated by GPT-4 should be vetted by a human before being used.
Following is the full conversation between me and GPT-4:
{
"rules": {
".read": false,
".write": false,
"environments": {
"$env": {
"rooms": {
"$roomId": {}
}
}
}
}
}
A room has an ownerId (firebase auth uid). A room can be created if not exists, and only the owner can read/write to the room afterwards.
To set up these rules, you can use the following JSON:
{
"rules": {
".read": false,
".write": false,
"environments": {
"$env": {
"rooms": {
"$roomId": {
".read": "root.child('environments').child($env).child('rooms').child($roomId).child('ownerId').val() === auth.uid",
".write": "(!data.exists() && newData.child('ownerId').val() === auth.uid) || (data.child('ownerId').val() === auth.uid && newData.child('ownerId').val() === auth.uid)"
}
}
}
}
}
}
Here's how this works:
- The
.read
rule checks if the user's Firebase Auth UID matches theownerId
of the room. - The
.write
rule has two parts, separated by '||':- The first part checks if the room doesn't exist (
!data.exists()
) and the user is setting their own UID as theownerId
(when creating the room). - The second part checks if the user's UID matches the
ownerId
of the existing room and the new data also has the sameownerId
. This ensures that only the owner can write to the room and theownerId
cannot be changed. :::
- The first part checks if the room doesn't exist (
this looks quite complex
.read
queries from the root, may be unnecessary, can we use data instead?.write
has validation logic baked in, maybe better to use.validate
instead?
You're right, we can simplify the rules and use .validate
for validation. Here's an updated version:
{
"rules": {
".read": false,
".write": false,
"environments": {
"$env": {
"rooms": {
"$roomId": {
".read": "data.child('ownerId').val() === auth.uid",
".validate": "newData.hasChild('ownerId')",
".write": "(!data.exists() && newData.child('ownerId').val() === auth.uid) || (data.exists() && data.child('ownerId').val() === auth.uid)"
}
}
}
}
}
}
Changes made:
- For the
.read
rule, we now usedata.child('ownerId').val() === auth.uid
to check if the user is the owner of the room without querying from the root. - We've added a
.validate
rule to ensure that the new data always contains anownerId
field.
The .write
rule still has two parts, but now it's simpler:
- The first part checks if the room doesn't exist (
!data.exists()
) and the user is setting their own UID as theownerId
(when creating the room). - The second part checks if the room exists (
data.exists()
) and the user's UID matches theownerId
of the existing room. This ensures that only the owner can write to the room. Since we have a.validate
rule now, there's no need to check if the new data has the sameownerId
.
this seems to allow the user to change the ownerId to someone else, doesn't it?
You are correct, my previous response did not properly prevent users from changing the ownerId
. I apologize for the confusion. Here's the updated version of the rules, which fixes the issue:
{
"rules": {
".read": false,
".write": false,
"environments": {
"$env": {
"rooms": {
"$roomId": {
".read": "data.child('ownerId').val() === auth.uid",
".write": "(!data.exists() && newData.child('ownerId').val() === auth.uid) || (data.exists() && data.child('ownerId').val() === auth.uid && newData.child('ownerId').val() === data.child('ownerId').val())",
"ownerId": {
".validate": "newData.val() === auth.uid"
}
}
}
}
}
}
}
In this version, the .write
rule checks that the new ownerId
is the same as the old ownerId
when updating the room data, ensuring that the ownerId
cannot be changed by the user.
Additionally, we added a .validate
rule specifically for the ownerId
field, which checks if the new ownerId
value is the same as the Firebase Auth UID of the user. This ensures that only the owner of the room can set or update the ownerId
.
i think the logic in .write and .validate is redundant. some logic in .write is already covered in .validate.
You are correct, there is some redundancy in the previous rules. Here's a more efficient version that eliminates the redundancy and ensures the desired behavior:
{
"rules": {
".read": false,
".write": false,
"environments": {
"$env": {
"rooms": {
"$roomId": {
".read": "data.child('ownerId').val() === auth.uid",
".write": "(!data.exists() && newData.child('ownerId').val() === auth.uid) || (data.exists() && data.child('ownerId').val() === auth.uid)",
"ownerId": {
".validate": "newData.val() === auth.uid"
}
}
}
}
}
}
}
In this version, we simplify the .write
rule to:
- Allow room creation if the room doesn't exist and the new
ownerId
is the user's UID. - Allow updates to the room data if the user's UID matches the existing
ownerId
.
The .validate
rule for the ownerId
ensures that the new ownerId
value is always the same as the user's Firebase Auth UID, so the user cannot change the ownerId
to someone else's UID.
This version removes the redundancy while maintaining the desired behavior.
this is very good! almost there, it does not prevent the owner from deleting the ownerId of their own room, because validate definitions are ignored when data is deleted (that is, when the new value being written is null)
You are correct, the previous rules do not prevent the owner from deleting the ownerId
of their own room. To fix this issue, we can add a condition in the .write
rule that ensures the new data always has the ownerId
field.
Here's the updated version of the rules:
{
"rules": {
".read": false,
".write": false,
"environments": {
"$env": {
"rooms": {
"$roomId": {
".read": "data.child('ownerId').val() === auth.uid",
".write": "(!data.exists() && newData.child('ownerId').val() === auth.uid) || (data.exists() && data.child('ownerId').val() === auth.uid && newData.hasChild('ownerId'))",
"ownerId": {
".validate": "newData.val() === auth.uid"
}
}
}
}
}
}
}
In this version, we added the newData.hasChild('ownerId')
condition to the .write
rule. This ensures that the new data must always contain the ownerId
field when updating the room data.
Now the rules prevent the owner from deleting the ownerId
while maintaining the desired behavior.
- i think the
newData.hasChild('ownerId')
part should go into .validate because all rooms need an ownerId, so it should never go missing - in the .write section, the
newData.child('ownerId').val() === auth.uid
clause in the left branch of the||
seems to be redundant with the ownerId’s .validate.
You're right, we can move the newData.hasChild('ownerId')
condition to the .validate
rule and simplify the .write
rule further. Here's the updated version of the rules:
{
"rules": {
".read": false,
".write": false,
"environments": {
"$env": {
"rooms": {
"$roomId": {
".validate": "newData.hasChild('ownerId')",
".read": "data.child('ownerId').val() === auth.uid",
".write": "(!data.exists() && auth.uid !== null) || (data.exists() && data.child('ownerId').val() === auth.uid)",
"ownerId": {
".validate": "newData.val() === auth.uid"
}
}
}
}
}
}
}
Changes made:
- We added the
newData.hasChild('ownerId')
condition to the.validate
rule, which ensures that all rooms have anownerId
field. - We simplified the
.write
rule by removing the redundantnewData.child('ownerId').val() === auth.uid
condition in the left branch of the||
. Now, the.write
rule checks if the room doesn't exist and the user is authenticated for room creation, or if the room exists and the user's UID matches the existingownerId
.
The updated rules now prevent the ownerId
from going missing and avoid redundancy.