1

In my database, I have a users node that contains data under a user ID. This includes their bio, number of followers, whether the user is a moderator, and more.

users
{
    userId1
    {
        bio: "Example bio..."
        followers: 250
        moderator: true
        ...
    }
}

In order for the number of followers to be correct, I use a transaction block to increment the followers property every time the follow button is clicked. There are a few other properties that require transaction blocks as well.

Unfortunately, I have discovered that in order for the transactions to work, the security rules for the $userId node must be set to: “.write”: “auth != null”. Otherwise, the number of followers property won’t be incremented when someone clicks the follow button. Because the transaction block queries the entire user, we can’t limit the security rules to just the “followers” property.

"users":
{
     "$userId": 
     {
        // Has to be set like this or transactions won't work
        ".read": "auth != null",
        ".write": "auth != null",


        "bio":
        {
            // This will have no effect due to rule cascading
            ".write": "auth.uid === $userId"
        }

        "moderator":
        {
            // This will have no effect due to rule cascading
            ".write": ...
        }
    }
}

And since rules cascade, this makes it seem impossible to set specific rules for any other properties under user, including bio and whether the user is a moderator etc. This makes the user property vulerable to changes by malicious users.

The same thing happens for a post and likes, the example used in the Firebase documentation. Because the transaction block queries the entire post, we can’t limit the security rules to just the “likes” property. All of the other post properties will have to settle for the “.write”: “auth !=null” setting because of cascading.

The best I can do is to use validation, but that won’t stop malicious users from setting their follow count to 10,000 or making themselves a moderator if they somehow gain access.

Using Firebase Rules, is there any way to secure nodes that have transactions run on them?


Edit: More Info

This is a simplified version of what my transaction block looks like for incrementing the follower count:

    // Run transaction block on the user in the "users" node
    userRef.runTransactionBlock({ (currentData: MutableData) -> TransactionResult in

        // Store the user 
        if var user = currentData.value as? [String: AnyObject]
        {
            // Get the number of followers
            var numberOfFollowers = user["numberOfFollowers"] as? Int ?? 0

            // Increase the number of followers by 1
            numberOfFollowers += 1


            // Set the new number of followers
            user["numberOfFollowers"] = numberOfFollowers as AnyObject?

            // Set the user value and report transaction success
            currentData.value = user

            return TransactionResult.success(withValue: currentData)
        }

        return TransactionResult.success(withValue: currentData)
    })

This is how followers are stored in my database:

myDatabase: {
    followers: {
        "andrew098239101": {
            // These are all the user ID's of users that follow "andrew098239101"
            "robert12988311": true
            "sarah9234298347": true
            "alex29101922": true
            "greg923749232": true
        }
        "robert12988311": {
            "alex29101922": true
        }
    }
    ...
    users: {
        "andrew098239101": {
             // Andrew's user info
             "bio": "hello I am Andrew"
             "numberOfFollowers": 4
             "moderator": true
             ...
        }
        "robert12988311": {
             "bio": "I'm Robert"
             "numberOfFollowers": 1
             "moderator": false
             ...
        }
    }
}

There is a similar node for following, etc.

MMK
  • 93
  • 1
  • 9
  • 1
    "I use a transaction block to increment the followers property every time the follow button is clicked" I'd actually recommend using `ServerValue.increment(1)` for this, as it'll be [more efficient](https://stackoverflow.com/questions/61536203/how-quickly-can-you-atomically-increment-a-value-on-the-firebase-realtime-databa). It shouldn't make a difference to the security rules that you're asking about though, as it's still the same user writing. – Frank van Puffelen Feb 24 '21 at 20:01
  • Thank you, I will try ServerValue.increment(1) to see if it makes a difference – MMK Feb 24 '21 at 20:27
  • 1
    Can you make ensure the data structure you post is valid JSON? There's no way to store the last structure you posted in Firebase's Realtime Database and the actual structure matters for how to secure it (and whether that's even possible). – Frank van Puffelen Feb 24 '21 at 21:44
  • Thanks for updating your answer, I updated the database example to be more clear. – MMK Feb 24 '21 at 21:58

1 Answers1

3

You should be able to set the looser write permission on the followers property only. So something like this:

"users": {
     "$userId":  {
        ".read": "auth != null",
        ".write": "auth.uid === $userId",
        "followers": {
            ".write": "auth.uid !== null"
        },
    }
}

With this, all authenticated users can write to /users/$uid/followers, but only the owner of a profile can write the other values.


I'd recommend separating the followers from the other user profile data, so that you have two top-level lists: users/$uid with the user profile that only the owner can write, and followers/$uid with the data that followers can write.


I also recommend not just storing the count, but also storing the UID of their followers. In fact, I'd use this structure:

users: {
  "uidOfMichael": {
    followers: {
      "uidOfFrank": true,
      "uidOfJames": true,
      "uidOfAndrew": true
    },
    followerCount: 3
  },
  "uidOfFrank": {
    followers: {
      "uidOfJames": true,
      "uidOfAndrew": true
    },
    followerCount: 2
  },
  "uidOfJames": {
    followers: {
      "uidOfAndrew": true
    },
    followerCount: 1
  },
  "uidOfAndrew": {
    followers: {
      "uidOfJames": true
    },
    followerCount: 1
  }
}

Now you can validate these additional things:

  1. A user can only write their own UID to followers.
  2. A user can only increment followerCount when they added their UID to followers

The rules for that would be something like (typos possible):

"users": {
     "$userId":  {
        ".read": "auth != null",
        ".write": "auth.uid === $userId",
        "followers": {
            "$followerId": {
                ".write": "auth.uid !== $followerId"
            }
        },
        "followerCount": {
            ".write": "
              newData.val() === data.val() + 1 &&
              !data.parent().child('followers').child(auth.uid).exists() &&
              newData.parent().child('followers').child(auth.uid).exists()
            "
        }
    }
}

Frank van Puffelen
  • 565,676
  • 79
  • 828
  • 807
  • Hi Frank, thank you for taking the time to answer. Unfortunately, the first example using ".write": "auth.uid === $userId" didn't work because it's not the user's own ID the transaction block is running on, but the ID of the other user being followed. I have a seperate "followers" node for keeping track of follower IDs. Thanks again, I will try and find a solution based on your answer and use ServerValue.increment(1) to see if it makes a difference. – MMK Feb 24 '21 at 20:23
  • 1
    It would be really helpful if you could show the code for the transaction. But in the first/current data model, the transaction should run on lowest node where it can do its work - which seems to be `followers` in there. – Frank van Puffelen Feb 24 '21 at 21:03
  • The "followers" node in my database isn't nested within "users", but is its own node in the database. All the users node does is keep track of the number of followers (and following), while the users IDs of followers are kept in another node. I can edit the original post to provide more information – MMK Feb 24 '21 at 21:29
  • Yeah, so that means you'll need to lock the entire database in the transaction, as (at the moment) Realtime Database transactions must run on a single common root-node. It might be better in that case to not use a transaction, but a multi-path update - and then just handle the rejection from the security rules by having the user click "like" again. Or alternatively, you could change the data model to more closely reflect what I have in my answer. – Frank van Puffelen Jun 13 '21 at 17:37