データのセキュリティ保護

Firebase Database ルールはデータベース用の宣言型の設定です。つまり、ルールはプロダクトのロジックとは別に定義されます。これには次のようないくつかの利点があります: クライアントがセキュリティの適用を担う必要がありません。バグのある実装でデータが危殆化することがありません。最も重要なこととして、サーバーなど、中間の審判を必要とせずにデータを外部から保護できるという利点もあります。

ルールの構造化

Firebase Database ルールは JSON ドキュメントに含まれている Javascript ライクの式で構成されます。ルールの構造はデータベースに保存しているデータの構造に従う必要があります。たとえば、メッセージのリストを追跡していて、次のようなデータがあるとします。

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

ルールは同様の方法で構造化する必要があります。このデータ構造に適したルールセットの例を次に示します。

{
  "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()"
      }
    }
  }
}

セキュリティ ルールの種類

セキュリティを適用するためのルールは .write.read.validate の 3 種類です。次に、その用途を簡潔に示します。

ルールの種類
.read ユーザーによるデータの読み取りを許可するときに記述します。
.write ユーザーによるデータの書き込みを許可するときに記述します。
.validate 値の適切なフォーマット方法、子属性を持つかどうか、およびデータ型を定義します。

事前定義された変数

数多くある便利な事前定義された変数には、セキュリティ ルールの定義内でアクセスできます。以下の例では、その変数の大半を使用しています。次に、それぞれの概要と適切な API リファレンスへのリンクを示します。

事前定義された変数
現在 Linux エポック時刻からの現在の時間(ミリ秒)。これは、SDK の firebase.database.ServerValue.TIMESTAMP で作成したタイムスタンプの検証に特に役に立ちます。
root Firebase データベース内のルートパスを表す RuleDataSnapshot。これは試みる操作の前に存在したとおりになります。
newData データを表す RuleDataSnapshot。このデータは試みる操作の後に存在することになるとおりになります。これには、書き込まれる新しいデータと既存のデータが含まれます。
data データを表す RuleDataSnapshot。このデータは試みる操作の前に存在したとおりになります。
$ variables ID と動的な子キーを表すために使用するワイルドカード パス。
auth 認証されたユーザーのトークン ペイロードを表します。

これらの変数はルール内の任意の場所で使用できます。たとえば、次のセキュリティ ルールでは、/foo/ ノードに書き込まれるデータを 100 文字未満の文字列にしなければならないことが保証されます。

{
  "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"
    }
  }
}

既存のデータと新規のデータ

事前定義された data 変数は、書き込み操作が行われる前のデータの参照に使用されます。一方、newData 変数には書き込み操作が成功した場合に存在することになる新規のデータが含まれています。newData は書き込まれた新しいデータと既存のデータを統合した結果を表します。

例として、次のルールでは、新しいレコードの作成や既存のレコードの削除はできますが、既存の null 以外のデータに変更を加えることはできません。

// 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()"

その他のパスにあるデータの参照

任意のデータをルールの基準として使用できます。事前定義された変数 rootdatanewData を使用すると、書き込みイベントの前後に存在するとおりに任意のパスにアクセスできます。

次の例を見てみましょう。これにより、/allow_writes/ ノードの値が true で、親ノードに readOnly フラグがセットされておらず、新しく書き込まれたデータに foo という名前の子がある限り、書き込み操作が許可されます。

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

読み取りルールと書き込みルールのカスケード

.read.write ルールはトップダウンで機能するため、浅いパスのルールが深いパスのルールよりも優先されます。ルールは、特定のパスで読み取り権限や書き込み権限を付与した場合、その下位にあるすべての子ノードへのアクセス権も付与します。 次の構造を見てみましょう。

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

このセキュリティ構造では、/foo/ に値が true の子 baz が含まれている場合に、/bar/ を読み取ることができます。ここでは、/foo/bar/ の下にある ".read": false ルールは影響しません。子パスではアクセス権を取り消すことができないからです。

直観的ではないかもしれませんが、これはルール言語の強力な利点であり、これにより大変複雑なアクセス権限を最小限の労力で実装できます。これについては、このガイドで後ほどユーザーベースのセキュリティについて説明するときに説明します。

.validate ルールはカスケード式に適用されるわけではないことに注意してください。書き込みが許可されるには、階層のすべてのレベルですべての検証ルールを満たす必要があります。

ルールはフィルターではない

ルールはアトミック(不可分)な方法で適用されます。つまり、該当の場所にも親の場所にもアクセス権を付与するルールが存在しなかった場合、直ちに、読み取り操作や書き込み操作は失敗します。影響するすべての子パスにアクセス可能な場合でも、親の場所での読み取りは完全に失敗します。次の構造を見てみましょう。

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

ルールがアトミックに評価されることを知らないと、/records/ パスをフェッチすると、rec1 は返されるが、rec2 は返されないと考えるかもしれません。しかし、実際の結果はエラーになります。

JaveScript
var db = firebase.database();
db.ref("records").once("value", function(snap) {
  // success method is not called
}, function(err) {
  // error callback triggered with PERMISSION_DENIED
});
Objective-C
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
}];
Swift
var ref = FIRDatabase.database().reference()
ref.child("records").observeSingleEventOfType(.Value, withBlock: { snapshot in
    // success block is not called
}, withCancelBlock: { error in
    // cancel block triggered with PERMISSION_DENIED
})
Java
FirebaseDatabase database = FirebaseDatabase.getInstance();
DatabaseReference ref = database.getReference("records");
ref.addListenerForSingleValueEvent(new ValueEventListener() {
  @Override
  public void onDataChange(DataSnapshot snapshot) {
    // success method is not called
  }

  @Override
  public void onCancelled(FirebaseError firebaseError) {
    // error callback triggered with PERMISSION_DENIED
  });
});
REST
curl https://docs-examples.firebaseio.com/rest/records/
# response returns a PERMISSION_DENIED error

/records/ での読み取り操作はアトミックである上、/records/ の下にあるすべてのデータへのアクセス権を付与する読み取りルールが存在しないため、PERMISSION_DENIED エラーがスローされます。Firebase console のセキュリティ シミュレータでこのルールを評価すると、読み取り操作が拒否されたことがわかります。

Attempt to read /records with auth=Success(null)
    /
    /records

No .read rule allowed the operation.
Read was denied.

/records/ パスに対する読み取りアクセスを許可するルールが存在しなかったため、この操作は拒否されました。ただし、リクエストしたパスに rec1 のルールが存在しなかったため、評価されることがなかったことに注意してください。rec1 をフェッチするには、それに直接アクセスする必要があります。

JaveScript
var db = firebase.database();
db.ref("records/rec1").once("value", function(snap) {
  // SUCCESS!
}, function(err) {
  // error callback is not called
});
Objective-C
FIRDatabaseReference *ref = [[FIRDatabase database] reference];
[[ref child:@"records/rec1"] observeSingleEventOfType:FEventTypeValue withBlock:^(FIRDataSnapshot *snapshot) {
    // SUCCESS!
}];
Swift
var ref = FIRDatabase.database().reference()
ref.child("records/rec1").observeSingleEventOfType(.Value, withBlock: { snapshot in
    // SUCCESS!
})
Java
FirebaseDatabase database = FirebaseDatabase.getInstance();
DatabaseReference ref = database.getReference("records/rec1");
ref.addListenerForSingleValueEvent(new ValueEventListener() {
  @Override
  public void onDataChange(DataSnapshot snapshot) {
    // SUCCESS!
  }

  @Override
  public void onCancelled(FirebaseError firebaseError) {
    // error callback is not called
  }
});
REST
curl https://docs-examples.firebaseio.com/rest/records/rec1
# SUCCESS!

データの検証

データ構造の適用や、データのフォーマットとコンテンツの検証は、.validate ルールを使用して実行する必要があります。このルールは、.write ルールでアクセス権の付与に成功した後にのみ実行されます。次に、.validate ルールのサンプル定義を示します。このルールでは、1900~2099 年までの YYYY-MM-DD 形式の日付のみが許可されます。これは正規表現を使用してチェックされます。

".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])$/)"

.validate ルールはカスケード式に適用されるわけではない唯一の種類のセキュリティ ルールです。ある子レコードで検証ルールが失敗した場合、書き込み操作全体が拒否されます。また、データが削除される(つまり、書き込まれる新しい値が null になる)と、検証の定義が無視されます。

これは些細なことのように思えますが、実際には、強力な Firebase Realtime Database ルールを書き込むための重要な機能です。次のルールを見てみましょう。

{
  "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()"
      }
    }
  }
}

このパターンに留意して、次の書き込み操作の結果を見てみましょう。

JaveScript
var ref = db.ref("/widget");

// PERMISSION_DENIED: does not have children color and size
ref.set('foo');

// PERMISSION DENIED: does not have child color
ref.set({size: 22});

// PERMISSION_DENIED: size is not a number
ref.set({ size: 'foo', color: 'red' });

// SUCCESS (assuming 'blue' appears in our colors list)
ref.set({ 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').set(99);
Objective-C
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];
Swift
var 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);
Java
FirebaseDatabase database = FirebaseDatabase.getInstance();
DatabaseReference ref = database.getReference("widget");

// PERMISSION_DENIED: does not have children color and size
ref.setValue("foo");

// PERMISSION DENIED: does not have child color
ref.child("size").setValue(22);

// PERMISSION_DENIED: size is not a number
Map<String,Object> map = new HashMap<String, Object>();
map.put("size","foo");
map.put("color","red");
ref.setValue(map);

// SUCCESS (assuming 'blue' appears in our colors list)
map = new HashMap<String, Object>();
map.put("size", 21);
map.put("color","blue");
ref.setValue(map);

// 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);
REST
# PERMISSION_DENIED: does not have children color and size
curl -X PUT -d 'foo' \
https://docs-examples.firebaseio.com/rest/securing-data/example.json

# PERMISSION DENIED: does not have child color
curl -X PUT -d '{"size": 22}' \
https://docs-examples.firebaseio.com/rest/securing-data/example.json

# PERMISSION_DENIED: size is not a number
curl -X PUT -d '{"size": "foo", "color": "red"}' \
https://docs-examples.firebaseio.com/rest/securing-data/example.json

# SUCCESS (assuming 'blue' appears in our colors list)
curl -X PUT -d '{"size": 21, "color": "blue"}' \
https://docs-examples.firebaseio.com/rest/securing-data/example.json

# 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
curl -X PUT -d '99' \
https://docs-examples.firebaseio.com/rest/securing-data/example/size.json

ここで、同じ構造を見てみましょう。ただし、.validate の代わりに .write ルールを使用しています。

{
  "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()"
      }
    }
  }
}

このパターンでは、以下の操作はいずれも成功します。

JaveScript
var ref = new Firebase(URL + "/widget");

// 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.set({size: 99999, 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.child('size').set(99);
Objective-C
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];
Swift
var ref = Firebase(url: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)
Java
Firebase ref = new Firebase(URL + "/widget");

// ALLOWED? Even though size is invalid, widget has children color and size,
// so write is allowed and the .write rule under color is ignored
Map<String,Object> map = new HashMap<String, Object>();
map.put("size", 99999);
map.put("color", "red");
ref.setValue(map);

// 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.child("size").setValue(99);
REST
# ALLOWED? Even though size is invalid, widget has children color and size,
# so write is allowed and the .write rule under color is ignored
curl -X PUT -d '{size: 99999, color: "red"}' \
https://docs-examples.firebaseio.com/rest/securing-data/example.json

# 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")
curl -X PUT -d '99' \
https://docs-examples.firebaseio.com/rest/securing-data/example/size.json

これは .write ルールと .validate ルールとの相違点について示しています。示したとおり、これらのルールはすべて .validate を使用して書き込まれる必要がありますが、newData.hasChildren() ルールは例外である可能性があります。これは削除が許可されている必要があるかどうかに応じて異なります。

$ Variables を使用したパスセグメントのキャプチャ

$ 接頭辞を持つキャプチャ変数を宣言することにより、読み取りや書き込みのパスの一部をキャプチャできます。これはワイルドカードとして機能し、ルール宣言の内部で使用するために該当のキーの値を保存します。

{
  "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')"
        }
      }
    }
  }
}

動的な $ 変数は定数値のパス名とともに使用することもできます。次の例では、$other 変数を使用して .validate ルールを宣言します。このルールにより、widget には titlecolor 以外に子が存在しないようになります。子が追加で作成される結果になるあらゆる書き込みは失敗します。

{
  "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 }
    }
  }
}

匿名チャットの例

ルールをまとめて、セキュアな匿名チャット アプリケーションを作成してみましょう。次の例では、ルールをリストすることに加え、動作可能なバージョンのチャットが含まれます。

{
  "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 }
        }
      }
    }
  }
}

次のステップ

フィードバックを送信...