firebase database: secure your data

Secure Your Data

Firebase Database Rules are declarative configuration for your database. This means that the rules are defined separately from the product logic. This has a number of advantages: clients aren't responsible for enforcing security, buggy implementations will not compromise your data, and perhaps most importantly, there is no need for an intermediate referee, such as a server, to protect data from the world.

Structuring Your Rules

Firebase Database Rules are made up of Javascript-like expressions contained in a JSON document. The structure of your rules should follow the structure of the data you have stored in your database. For example, let's say you are keeping track of a list of messages and have data that looks like this:

{
 
"messages": {
   
"message0": {
     
"content": "Hello",
     
"timestamp": 1405704370369
   
},
   
"message1": {
     
"content": "Goodbye",
     
"timestamp": 1405704395231
   
},
   
...
 
}
}

Your rules should be structured in a similar manner. Here's an example set of rules that might make sense for this data structure.

{
 
"rules": {
   
"messages": {
     
"$message": {
       
// only messages from the last ten minutes can be read
       
".read": "data.child('timestamp').val() > (now - 600000)",

       
// new messages must have a string content and a number timestamp
       
".validate": "newData.hasChildren(['content', 'timestamp']) && newData.child('content').isString() && newData.child('timestamp').isNumber()"
     
}
   
}
 
}
}

Types of Security Rules

There are three type of rules for enforcing security: .write.read, and .validate. Here is a quick summary of their purposes:

Rule Types
.readDescribes if and when data is allowed to be read by users.
.writeDescribes if and when data is allowed to be written.
.validateDefines what a correctly formatted value will look like, whether it has child attributes, and the data type.
Note: Access is disallowed by default. If no .write or .read rule is specified at or above a path, access will be denied.

Predefined Variables

There are a number of helpful, predefined variables that can be accessed inside a security rule definition. We'll be using most of them in the examples below. Here is a brief summary of each and a link to the appropriate API reference.

Predefined Variables
nowThe current time in milliseconds since Linux epoch. This works particularly well for validating timestamps created with the SDK's firebase.database.ServerValue.TIMESTAMP.
rootRuleDataSnapshot representing the root path in the Firebase database as it exists before the attempted operation.
newDataRuleDataSnapshot representing the data as it would exist after the attempted operation. It includes the new data being written and existing data.
dataRuleDataSnapshot representing the data as it existed before the attempted operation.
$ variablesA wildcard path used to represent ids and dynamic child keys.
authRepresents an authenticated user's token payload.

These variables can be used anywhere in your rules. For example, the security rules below ensure that data written to the /foo/ node must be a string less than 100 characters:

{
 
"rules": {
   
"foo": {
     
// /foo is readable by the world
     
".read": true,

     
// /foo is writable by the world
     
".write": true,

     
// data written to /foo must be a string less than 100 characters
     
".validate": "newData.isString() && newData.val().length < 100"
   
}
 
}
}

Existing Data vs. New Data

The predefined data variable is used to refer to the data before a write operation takes place. Conversely, the newData variable contains the new data that will exist if the write operation is successful. newData represents the merged result of the new data being written and existing data.

To illustrate, this rule would allow us to create new records or delete existing ones, but not to make changes to existing non-null data:

// we can write as long as old data or new data does not exist
// in other words, if this is a delete or a create, but not an update
".write": "!data.exists() || !newData.exists()"Make sure to check for null or invalid data. Errors in rules lead to rejected operations.

Referencing Data in other Paths

Any data can be used as criterion for rules. Using the predefined variables rootdata, and newData, we can access any path as it would exist before or after a write event.

Consider this example, which allows write operations as long as the value of the /allow_writes/node is true, the parent node does not have a readOnly flag set, and there is a child named foo in the newly written data:

".write": "root.child('allow_writes').val() === true &&
          !data.parent().child('readOnly').exists() &&
          newData.child('foo').exists()"

Read and Write Rules Cascade

Note: Shallower security rules override rules at deeper paths. Child rules can only grant additional privileges to what parent nodes have already declared. They cannot revoke a read or write privilege.

.read and .write rules work from top-down, with shallower rules overriding deeper rules. If a rule grants read or write permissions at a particular path, then it also grants access to all child nodes under it. Consider the following structure:

{
 
"rules": {
     
"foo": {
       
// allows read to /foo/*
       
".read": "data.child('baz').val() === true",
       
"bar": {
         
/* ignored, since read was allowed already */
         
".read": false
       
}
     
}
 
}
}

This security structure allows /bar/ to be read from whenever /foo/ contains a child baz with value true. The ".read": false rule under /foo/bar/ has no effect here, since access cannot be revoked by a child path.

While it may not seem immediately intuitive, this is a powerful part of the rules language and allows for very complex access privileges to be implemented with minimal effort. This will be illustrated when we get into user-based security later in this guide.

Note that .validate rules do not cascade. All validate rules must be satisfied at all levels of the hierarchy in order for a write to be allowed.

Rules Are Not Filters

Rules are applied in an atomic manner. That means that a read or write operation is failed immediately if there isn't a rule at that location or at a parent location that grants access. Even if every affected child path is accessible, reading at the parent location will fail completely. Consider this structure:

{
 
"rules": {
   
"records": {
     
"rec1": {
       
".read": true
     
},
     
"rec2": {
       
".read": false
     
}
   
}
 
}
}

Without understanding that rules are evaluated atomically, it might seem like fetching the /records/path would return rec1 but not rec2. The actual result, however, is an error:

JAVASCRIPT
OBJECTIVE-C
SWIFT
JAVA
REST
FIRDatabaseReference *ref = [[FIRDatabase database] reference];
[[_ref child:@"records"] observeSingleEventOfType:FIRDataEventTypeValue withBlock:^(FIRDataSnapshot *snapshot) {
 
// success block is not called
} withCancelBlock:^(NSError * _Nonnull error) {
 
// cancel block triggered with PERMISSION_DENIED
}];

Since the read operation at /records/ is atomic, and there's no read rule that grants access to all of the data under /records/, this will throw a PERMISSION_DENIED error. If we evaluate this rule in the security simulator in our Firebase console, we can see that the read operation was denied:

Attempt to read /records with auth=Success(null) / /records No .read rule allowed the operation. Read was denied.

The operation was denied because no read rule allowed access to the /records/ path, but note that the rule for rec1 was never evaluated because it wasn't in the path we requested. To fetch rec1, we would need to access it directly:

JAVASCRIPT
OBJECTIVE-C
SWIFT
JAVA
REST
FIRDatabaseReference *ref = [[FIRDatabase database] reference];
[[ref child:@"records/rec1"] observeSingleEventOfType:FEventTypeValue withBlock:^(FIRDataSnapshot *snapshot) {
   
// SUCCESS!
}];

Query-based Rules

Although you can't use rules as filters, you can limit access to subsets of data by using query parameters in your rules. Use query. expressions in your rules to grant read or write access based on query parameters.

For example, the following query-based rule uses user-based security rules and query-based rules to restrict access to data in the baskets collection to only the shopping baskets the active user owns:

"baskets": {
 
".read": "auth.uid != null &&
            query.orderByChild == 'owner' &&
            query.equalTo == auth.uid"
// restrict basket access to owner of basket
}

The following query, which includes the query parameters in the rule, would succeed:

db.ref("baskets").orderByChild("owner")
                 
.equalTo(auth.currentUser.uid)
                 
.on("value", cb)                 // Would succeed

However, queries that do not include the parameters in the rule would fail with a PermissionDeniederror:

db.ref("baskets").on("value", cb)                 // Would fail with PermissionDenied

You can also use query-based rules to limit how much data a client downloads through read operations.

For example, the following rule limits read access to only the first 1000 results of a query, as ordered by priority:

messages: {
 
".read": "query.orderByKey &&
            query.limitToFirst <= 1000"

}

// Example queries:

db
.ref("messages").on("value", cb)                // Would fail with PermissionDenied

db
.ref("messages").limitToFirst(1000)
                 
.on("value", cb)                // Would succeed (default order by key)

The following query. expressions are available in Firebase Database Rules.

Query-based rule expressions
ExpressionTypeDescription
query.orderByKey
query.orderByPriority
query.orderByValue
booleanTrue for queries ordered by key, priority, or value. False otherwise.
query.orderByChildstring
null
Use a string to represent the relative path to a child node. For example, query.orderByChild == "address/zip". If the query isn't ordered by a child node, this value is null.
query.startAt
query.endAt
query.equalTo
string
number
boolean
null
Retrieves the bounds of the executing query, or returns null if there is no bound set.
query.limitToFirst
query.limitToLast
number
null
Retrieves the limit on the executing query, or returns null if there is no limit set.

Validating Data

Enforcing data structures and validating the format and content of data should be done using.validate rules, which are run only after a .write rule succeeds to grant access. Below is a sample .validate rule definition which only allows dates in the format YYYY-MM-DD between the years 1900-2099, which is checked using a regular expression.

".validate": "newData.isString() &&
              newData.val().matches(/^(19|20)[0-9][0-9][-\\/. ](0[1-9]|1[012])[-\\/. ](0[1-9]|[12][0-9]|3[01])$/)"
Try it on JSFiddleClick here to see this in action. Try writing different values to the input field.

The .validate rules are the only type of security rule which do not cascade. If any validation rule fails on any child record, the entire write operation will be rejected. Additionally, the validate definitions are ignored when data is deleted (that is, when the new value being written is null).

Note: The .validate rules are only evaluated for non-null values and do not cascade.

These might seem like trivial points, but are in fact significant features for writing powerful Firebase Realtime Database Rules. Consider the following rules:

{
 
"rules": {
   
// write is allowed for all paths
   
".write": true,
   
"widget": {
     
// a valid widget must have attributes "color" and "size"
     
// allows deleting widgets (since .validate is not applied to delete rules)
     
".validate": "newData.hasChildren(['color', 'size'])",
     
"size": {
       
// the value of "size" must be a number between 0 and 99
       
".validate": "newData.isNumber() &&
                      newData.val() >= 0 &&
                      newData.val() <= 99"

     
},
     
"color": {
       
// the value of "color" must exist as a key in our mythical
       
// /valid_colors/ index
       
".validate": "root.child('valid_colors/' + newData.val()).exists()"
     
}
   
}
 
}
}

With this variant in mind, look at the results for the following write operations:

JAVASCRIPT
OBJECTIVE-C
SWIFT
JAVA
REST
FIRDatabaseReference *ref = [[[FIRDatabase database] reference] child: @"widget"];

// PERMISSION_DENIED: does not have children color and size
[ref setValue: @"foo"];

// PERMISSION DENIED: does not have child color
[ref setValue: @{ @"size": @"foo" }];

// PERMISSION_DENIED: size is not a number
[ref setValue: @{ @"size": @"foo", @"color": @"red" }];

// SUCCESS (assuming 'blue' appears in our colors list)
[ref setValue: @{ @"size": @21, @"color": @"blue" }];

// If the record already exists and has a color, this will
// succeed, otherwise it will fail since newData.hasChildren(['color', 'size'])
// will fail to validate
[[ref child:@"size"] setValue: @99];

Now let's look at the same structure, but using .write rules instead of .validate:

{
 
"rules": {
   
// this variant will NOT allow deleting records (since .write would be disallowed)
   
"widget": {
     
// a widget must have 'color' and 'size' in order to be written to this path
     
".write": "newData.hasChildren(['color', 'size'])",
     
"size": {
       
// the value of "size" must be a number between 0 and 99, ONLY IF WE WRITE DIRECTLY TO SIZE
       
".write": "newData.isNumber() && newData.val() >= 0 && newData.val() <= 99"
     
},
     
"color": {
       
// the value of "color" must exist as a key in our mythical valid_colors/ index
       
// BUT ONLY IF WE WRITE DIRECTLY TO COLOR
       
".write": "root.child('valid_colors/'+newData.val()).exists()"
     
}
   
}
 
}
}

In this variant, any of the following operations would succeed:

JAVASCRIPT
OBJECTIVE-C
SWIFT
JAVA
REST
Firebase *ref = [[Firebase alloc] initWithUrl:URL];

// ALLOWED? Even though size is invalid, widget has children color and size,
// so write is allowed and the .write rule under color is ignored
[ref setValue: @{ @"size": @9999, @"color": @"red" }];

// ALLOWED? Works even if widget does not exist, allowing us to create a widget
// which is invalid and does not have a valid color.
// (allowed by the write rule under "color")
[[ref childByAppendingPath:@"size"] setValue: @99];

This illustrates the differences between .write and .validate rules. As demonstrated, all of these rules should be written using .validate, with the possible exception of the newData.hasChildren()rule, which would depend on whether deletions should be allowed.

Note: Validation rules are not meant to completely replace data validation code in your app. We recommend that you also perform input validation client-side for best performance and best user experience when your app is offline.

Using $ Variables to Capture Path Segments

You can capture portions of the path for a read or write by declaring capture variables with the $prefix. This serves as a wild card, and stores the value of that key for use inside the rules declarations:

{
 
"rules": {
   
"rooms": {
     
// this rule applies to any child of /rooms/, the key for each room id
     
// is stored inside $room_id variable for reference
     
"$room_id": {
       
"topic": {
         
// the room's topic can be changed if the room id has "public" in it
         
".write": "$room_id.contains('public')"
       
}
     
}
   
}
 
}
}

The dynamic $ variables can also be used in parallel with constant path names. In this example, we're using the $other variable to declare a .validate rule that ensures that widget has no children other than title and color. Any write that would result in additional children being created would fail.

{
 
"rules": {
   
"widget": {
     
// a widget can have a title or color attribute
     
"title": { ".validate": true },
     
"color": { ".validate": true },

     
// but no other child paths are allowed
     
// in this case, $other means any key excluding "title" and "color"
     
"$other": { ".validate": false }
   
}
 
}
}Note: Path keys are always strings. For this reason, it's important to keep in mind that when we attempt to compare a $ variable to a number, this will always fail. This can be corrected by converting the number to a string (e.g. $key === newData.val()+'')

Anonymous Chat Example

Let's put the rules together and create a secure, anonymous chat application. Here we'll list the rules, and a functional version of the chat is included below:

{
 
"rules": {
   
// default rules are false if not specified
   
// setting these to true would make ALL CHILD PATHS readable/writable
   
// ".read": false,
   
// ".write": false,

   
"room_names": {
     
// the room names can be enumerated and read
     
// they cannot be modified since no write rule
     
// explicitly allows this
     
".read": true,

     
"$room_id": {
       
// this is just for documenting the structure of rooms, since
       
// they are read-only and no write rule allows this to be set
       
".validate": "newData.isString()"
     
}
   
},

   
"messages": {
     
"$room_id": {
       
// the list of messages in a room can be enumerated and each
       
// message could also be read individually, the list of messages
       
// for a room cannot be written to in bulk
       
".read": true,

       
// room we want to write a message to must be valid
       
".validate": "root.child('room_names/'+$room_id).exists()",

       
"$message_id": {
         
// a new message can be created if it does not exist, but it
         
// cannot be modified or deleted
         
".write": "!data.exists() && newData.exists()",
         
// the room attribute must be a valid key in room_names/ (the room must exist)
         
// the object to write must have a name, message, and timestamp
         
".validate": "newData.hasChildren(['name', 'message', 'timestamp'])",

         
// the name must be a string, longer than 0 chars, and less than 20 and cannot contain "admin"
         
"name": { ".validate": "newData.isString() && newData.val().length > 0 && newData.val().length < 20 && !newData.val().contains('admin')" },

         
// the message must be longer than 0 chars and less than 50
         
"message": { ".validate": "newData.isString() && newData.val().length > 0 && newData.val().length < 50" },

         
// messages cannot be added in the past or the future
         
// clients should use firebase.database.ServerValue.TIMESTAMP
         
// to ensure accurate timestamps
         
"timestamp": { ".validate": "newData.val() <= now" },

         
// no other fields can be included in a message
         
"$other": { ".validate": false }
       
}
     
}
   
}
 
}
}Try it on JSFiddle: Feel free to try out some of the Firebase Realtime Database Rules above by clicking on this interactive demo which implements all the rules above in a functional chat.Writing data with transactions: For a transaction to write data, it must also be able to read the path specified. So both read and write must evaluate to true before it can succeed.

Next Steps

發佈了5 篇原創文章 · 獲贊 5 · 訪問量 4萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章