notes.dt.in.th

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:

Me:
{
  "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 the ownerId 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 the ownerId (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 same ownerId. This ensures that only the owner can write to the room and the ownerId cannot be changed. :::
Me:

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 use data.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 an ownerId 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 the ownerId (when creating the room).
  • The second part checks if the room exists (data.exists()) and the user's UID matches the ownerId 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 same ownerId.
Me:

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.

Me:

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.

Me:

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.

Me:
  • 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 an ownerId field.
  • We simplified the .write rule by removing the redundant newData.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 existing ownerId.

The updated rules now prevent the ownerId from going missing and avoid redundancy.