A few weeks ago, I wrote about how I built a Designer News clone with AngularJS and Firebase. A lot of the feedback I received was about how the application could be secure because it is written purely with front-end code. So this week, I tried to implement Firebase's built-in security rules.

Now, I'm not a back-end developer. A lot of this was brand new to me, but after spending days going over the documentation, I was able to successfully add security rules to my application. What I came up with may not be the best way to do it, but it worked, and that's at least half the battle!

Introduction to Firebase Security

Format

Firebase security rules are written in json format, and are structured to mirror your data. So, for example, if your data is structured like this -

stories: {  
  story1: {
    title: "Story 1 Title Here",
    description: "Story 1 Description Here"
  },
 story2: {
    title: "Story 2 Title Here",
    description: "Story 2 Description Here"
  } 
} 

The security rules will be structured like this -

{
  "rules": {
    "stories": {
      "$story_id": {
        // rules go here
        "title": { 
          // more rules go here
        },
        "description": { 
          // more rules go here
        }
      } 
    } 
  }
}

Rule Types

There are three types of rules you can apply to each of the nodes -

Rule Type Description
.read Describes if and when data is allowed to be read by users.
.write Describes if and when data is allowed to be written.
.validate Defines what a correctly formatted value will look like, whether it has child attributes, and the data type.

(taken form Firebase docs )

To apply these rule types, you need to have a corresponding statement which must resolve as true. For example -

{
  "rules": {
    "foo": {
      // foo can be read by anyone
      ".read": true,

      // data can only be written to foo if the user is logged in
      ".write": "auth !== null",

      // new data being written to foo must be a string
      ".validate": "newData.isString()"

    } 
  }
}

You can find the list of methods used to create these statements in the Firebase API Reference.

Rules Cascade

A critical concept about the way the security rules work is the rules cascade. As the Firebase docs explain -

The child rules can only grant additional privileges to what parent nodes have already declared. They cannot revoke a read or write privilege.

This means that if you set a .read rule for a parent node, then that rule applies to all child nodes. If you declare another .read rule in the child node, it will be ignored.

{
  "rules": {
    "foo": {
      // foo can be read by anyone
      ".read": true,

      "bar": {
        // ignored because read was already allowed
        // so bar can also be read by anyone
        ".read": false
      }
    } 
  }
}

Referincing Data

When referencing the data under the current node, a distinction is made between data and newData -

Rule Type Description
.read Describes if and when data is allowed to be read by users.
.write Describes if and when data is allowed to be written.
.validate Defines what a correctly formatted value will look like, whether it has child attributes, and the data type.

To refer to data from other nodes, you can use one of the following -

Type Description Example
parent() The parent of the current node data.parent().child('sibling')
child(childPath) The child of the current node data.child('title').val().isString()
root The current data at the root of your Firebase root.child('foo').val()

Implementing Security to the Designer News Clone

Because of the rules cascade issue, I had to restructure my data a bit. Here is how it is now organised -

stories: {  
  story1_id: {
    title:
    date:
    url:
    user: {
      first_name:
      last_name:
      title:
      id:
    }
    voteCount
    voters
    commentCount
    comments: { [array of commments] }
  }
}
users: {  
  user1_id: {
    first_name:
    last_name:
    title:
    uid:
    karma:
    posts: { [array of posts] }
    comments: { [array of comments] }
  } 
}
karma: {  
  user1_id: [number],  
  user2_id: [number],  
} 

The main things I changed were -

  • For each user in the users array, I set the Firebase key to be equal to the authentication ID
  • Removed the karma from each individual user object, and moved it into its 'karma' own array

Based on the way my data is now structures, here is how my rules file looks -

{ "rules": {

  // Anyone can read any data
   ".read": true,

    "stories": {
      // Each story
      "$story_id": {

        // Only logged in users can create a story
        ".write": "auth !== null",

        "title": {
          // Validate only if the data doesn't already exist (so data cannot be edited) and if the new data is a string
          ".validate": "!data.exists() && newData.isString()"
        },
        "date": { 
          // Validate only if the data doesn't already exist, and the data must be equal to the current datetime
          ".validate": "!data.exists() && newData.val() <= now"
        },
        "url": {
          ".validate": "!data.exists() && newData.isString()"
        },
        "description": {
          ".validate": "!data.exists() && newData.isString()"
        },
        "user": {
          // Validate only if the data doesn't already exist, and the data must have the specified children
          ".validate": "!data.exists() && newData.hasChildren(['first_name', 'last_name', 'title', 'id'])"
        },
        "voteCount": {
          // Data can only be validated to this node if -
            // The data is new and the value is equal to 0 OR
            // The data already exists, the new value is equal to the old value plus 1, and the user making this addition is not the author of the story
          ".validate": "( !data.exists() && newData.val() === 0 )
                        || ( data.exists() && newData.val() === data.val() + 1 && auth.uid !== root.child('stories').child($story_id).child('user').child('id').val() )"
        },
        "voters": {
          ".validate": true
        },
        "commentCount": {
          ".validate": true
        },
        "comments": {
          "$comment_id": {

            "voteCount": {
              // Data can only be validated to this node if -
                // The data is new and the value is equal to 0 OR
                // If data already exists, the new value is equal to the old value plus 1, and the user making this addition is not the author of the comment
              ".validate": "( !data.exists() && newData.val() === 0 )
                          || ( data.exists() && newData.val() === data.val() + 1 && auth.uid !== root.child('stories').child($story_id).child('comments').child($comment_id).child('user').child('id').val() )"
            }
          }
        }
      }
    },

    "users": {
      // Each user
      "$user_id": {

        // Data can only be written if -
          // The user doesn't already exist OR
          // The user does exist and is the same as the current authenticated user
        ".write": "!data.exists() || ( data.exists() && auth.uid === $user_id )",

        "uid": {
          // Data can only be validated to this node if -
            // The data doesn't already exist OR
            // The new data is equal to the old data, i.e. the data not be changed once set
          ".validate": "!data.exists() || data.val() === newData.val()"
        },
        "first_name": {
          // Data can only be validated to this node if -
            // The new data is a string OR
            // No new data is incoming, i.e. the data is being deleted
          ".validate": "newData.isString() || !newData.exists()"
        },
        "last_name": {
          ".validate": "newData.isString() || !newData.exists()"
        },
        "title": {
          ".validate": "newData.isString() || !newData.exists()"
        }
      }
    },

    "karma": {
      // Each user's karma
      "$user_id": {

        // Data can only be written if -
          // The user doesnt already exist OR
          // No new data is incoming, i.e. the data is being deleted OR
          // The current authenticated user adding to this user's karma is not the same as this user, i.e. you can't add to your own karma
        ".write": "!data.exists() || !newData.exists() || ( auth !== null && auth.uid !== $user_id )",

        // Data can only be validated to this node if The
          // The data doesn't already exist OR
          // Data does exist and the new karma is equal to the old value plus 1
        ".validate": "!data.exists() || ( data.exists() && newData.val() === data.val() + 1 )"
      }
    }
} }

 

That's it! One thing I am still struggling with is out to make sure that a user can only vote once. As far as I know, there is no way to loop through an array of data in the Firebase security methods to check if specific data exists.

If you know of a way to do this, or if you have any other feedback, leave a comment below.