54
votes

I'm writing an app that allows users to submit nominations which are moderated before being displayed to other users. This requires a number of restrictions I've so far been unsuccessful in implementing with security rules:

  1. Hide any nominations that haven't been approved yet
  2. Hide private fields from submission (phone, approval status, creation date etc.)

My current rules are as follows:

{
    "rules": {
        "nominations": {
            ".read": true,

            "$nominationId": {
                ".read": "data.child('state').val() == 'approved' || auth != null", // Only read approved nominations if not authenticated
                ".write": "!data.exists()", // Only allow new nominations to be created

                "phone": {
                    ".read": "auth != null" // Only allow authenticated users to read phone number
                },

                "state": {
                    ".read": "auth != null", // Only allow authenticated users to read approval state
                    ".write": "auth != null" // Only allow authenticated users to change state
                }
            }
        }
    }
}

Child rules (e.g. $nomination) don't prevent the entire child from being read from the parent. If I listen for child_added on https://my.firebaseio.com/nominations it happily returns all children and all their data even with the above security rules in place.

My current workaround idea for this is to keep a separate node named approved and simply move the data between lists whenever someone approves or rejects a nomination, but it seems like a horribly broken approach.

Update

Following Michael Lehenbauer's excellent comment I've reimplemented the initial idea with minimal effort.

The new data structure is as follows:

my-firebase
    |
    `- nominations
        |
        `- entries
        |   |
        |   `- private
        |   `- public
        |
        `- status
            |
            `- pending
            `- approved
            `- rejected

Each nomination is stored under entries with private data such as phone number, e-mail etc. under private and publicly viewable data under public.

The updated rules are as follows:

{
    "rules": {
        "nominations": {
            "entries": {
                "$id": {
                    ".write": "!data.exists()",

                    "public": {
                        ".read": true,
                    },

                    "private": {
                        ".read": "auth != null"
                    }
                }
            },

            "status": {
                "pending": {
                    ".read": "auth != null",

                    "$id": {
                        ".write": "root.child('nominations/entries').child($id).exists() && (auth != null || newData.val() == true)"
                    }
                },

                "approved": {
                    ".read": true,

                    "$id": {
                        ".write": "root.child('nominations/entries').child($id).exists() && auth != null"
                    }
                },


                "rejected": {
                    ".read": "auth != null",

                    "$id": {
                        ".write": "root.child('nominations/entries').child($id).exists() && auth != null"
                    }
                }
            }
        }
    }
}

And the JavaScript implementation:

var db = new Firebase('https://my.firebaseio.com')
var nominations = db.child('nominations')

var entries = nominations.child('entries')

var status = nominations.child('status')
var pending = status.child('pending')
var approved = status.child('approved')
var rejected = status.child('rejected')

// Create nomination via form input (not shown)
var createNomination = function() {
    var data = {
        public: {
            name: 'Foo',
            age: 20
        },

        private: {
            createdAt: new Date().getTime(),
            phone: 123456
        }
    }

    var nomination = entries.push()
    nomination.setWithPriority(data, data.private.createdAt)

    pending.child(nomination.name()).set(true)    
}

// Retrieve current nomination status
var getStatus = function(id, callback) {
    approved.child(id).once('value', function(snapshot) {
        if (snapshot.val()) {
            callback(id, 'approved')
        } else {
            rejected.child(id).once('value', function(snapshot) {
                callback(id, snapshot.val() ? 'rejected' : 'pending')
            })
        }
    })
}

// Change status of nomination
var changeStatus = function(id, from, to) {
    status.child(from).child(id).remove()
    status.child(to).child(id).set(true)
}

The only part of the implementation I'm struggling with is handling status changes, my current approach can surely be improved upon:

_.each([pending, approved, rejected], function(status) {
    status.on('child_added', function(snapshot) {
        $('#' + snapshot.name()).removeClass('pending approved rejected').addClass(status.name())
    })
})

I was planning on using child_changed on nominations/status but I haven't been able to get it working reliably.

3

3 Answers

50
votes

Kato's right. It's important to understand that security rules never filter data. For any location, you'll either be able to read all of the data (including its children) or none of it. So in the case of your rules, having a ".read": true under "nominations" negates all of your other rules.

So the approach I'd recommend here is to have 3 lists. One containing nomination data, one to contain the list of approved nominations, and one to contain the list of pending nominations.

Your rules could be like so:

{
  "rules": {
    // The actual nominations.  Each will be stored with a unique ID.
    "nominations": {
      "$id": {
        ".write": "!data.exists()", // anybody can create new nominations, but not overwrite existing ones.
        "public_data": {
          ".read": true // everybody can read the public data.
        },
        "phone": {
          ".read": "auth != null", // only authenticated users can read the phone number.
        }
      }
    },
    "approved_list": {
      ".read": true, // everybody can read the approved nominations list.
      "$id": {
        // Authenticated users can add the id of a nomination to the approved list 
        // by creating a child with the nomination id as the name and true as the value.
        ".write": "auth != null && root.child('nominations').child($id).exists() && newData.val() == true"
      }
    },
    "pending_list": {
      ".read": "auth != null", // Only authenticated users can read the pending list.
      "$id": {
        // Any user can add a nomination to the pending list, to be moderated by
        // an authenticated user (who can then delete it from this list).
        ".write": "root.child('nominations').child($id).exists() && (newData.val() == true || auth != null)"
      }
    }
  }
}

An unauthenticated user could add a new nomination with:

var id = ref.child('nominations').push({ public_data: "whatever", phone: "555-1234" });
ref.child('pending_list').child(id).set(true);

An authenticated user could approve a message with:

ref.child('pending_list').child(id).remove();
ref.child('approved_list').child(id).set(true);

And to render the approved and pending lists you'd use code something like:

ref.child('approved_list').on('child_added', function(childSnapshot) {
  var nominationId = childSnapshot.name();
  ref.child('nominations').child(nominationId).child('public_data').on('value', function(nominationDataSnap) {
    console.log(nominationDataSnap.val());
  });
});

In this way, you use approved_list and pending_list as lightweight lists that can be enumerated (by unauthenticated and authenticated users respectively) and store all of the actual nomination data in the nominations list (which nobody can enumerate directly).

4
votes

If I fully grok the way security rules work (I'm just learning them myself), then when any one rule allows access, access is granted. Thus, they are read as follows:

  • nominations ".read": true, ACCESS GRANTED
  • other rules: not read

Furthermore, if that rule is removed, $nominationId ".read" grants access if the record is approved; therefore, the .read in phone and state become superfluous whenever it's approved.

It would probably be simplest to break this down into public/ and private/ children, like so:

nominations/unapproved/          # only visible to logged in users
nominations/approved/            # visible to anyone (move record here after approval)
nominations/approved/public/     # things everyone can see
nominations/approved/restricted/ # things like phone number, which are restricted

UPDATE

Thinking this over even more, I think you'll still encounter an issue with making approved/ public, which will allow you to list the records, and having approved/restricted/ private. The restricted data might need its own path as well in this use case.

-1
votes

this thread is a little outdated and it may have a solution via rules, but as the video says, its a neat trick: https://youtu.be/5hYMDfDoHpI?t=8m50s

This may be not a good practice since the firebase docs say that rules are not filters: https://firebase.google.com/docs/database/security/securing-data

I'm no specialist in security but I tested the trick and it worked fine for me. :)

So I hope a better understanding of the security issues around this implementation.