Fonctionnement des règles de sécurité

La sécurité peut être l'un des éléments les plus complexes du puzzle du développement d'applications. Dans la plupart des applications, les développeurs doivent créer et exécuter un serveur qui gère l'authentification (l'identité de l'utilisateur) et l'autorisation (ce que l'utilisateur peut faire).

Firebase Security Rules supprime la couche intermédiaire (serveur) et vous permet de spécifier des autorisations basées sur le chemin d'accès pour les clients qui se connectent directement à vos données. Consultez ce guide pour en savoir plus sur l'application des règles aux requêtes entrantes.

Sélectionnez un produit pour en savoir plus sur ses règles.

Cloud Firestore

Structure de base

Firebase Security Rules dans Cloud Firestore et Cloud Storage utilise la structure et la syntaxe suivantes:

service <<name>> {
  // Match the resource path.
  match <<path>> {
    // Allow the request if the following conditions are true.
    allow <<methods>> : if <<condition>>
  }
}

Vous devez comprendre les concepts clés suivants lorsque vous créez des règles:

  • Request (Requête) : méthode ou méthodes appelées dans l'instruction allow. Il s'agit des méthodes que vous autorisez à s'exécuter. Les méthodes standards sont les suivantes: get, list, create, update et delete. Les méthodes pratiques read et write permettent un accès en lecture et en écriture étendu sur la base de données ou le chemin d'accès au stockage spécifié.
  • Chemin:emplacement de la base de données ou de l'emplacement de stockage, représenté par un chemin d'URI.
  • Règle:instruction allow, qui inclut une condition qui autorise une requête si elle renvoie la valeur "true".

Règles de sécurité version 2

La version 2 des règles de sécurité Firebase est disponible depuis mai 2019. Elle modifie le comportement des caractères génériques récursifs {name=**}. Vous devez utiliser cette version si vous prévoyez d'utiliser des requêtes de groupe de collections. Pour activer la version 2, mettez rules_version = '2'; sur la première ligne de vos règles de sécurité:

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {

Chemins correspondants

Toutes les instructions de correspondance doivent pointer vers des documents, et non vers des collections. Une instruction de correspondance peut pointer vers un document spécifique, comme dans match /cities/SF, ou utiliser des caractères génériques pour pointer vers un document du chemin spécifié, comme dans match /cities/{city}.

Dans l'exemple ci-dessus, l'instruction de correspondance utilise la syntaxe de caractère générique {city}. Cela signifie que la règle s'applique à tous les documents de la collection cities, tels que /cities/SF ou /cities/NYC. Lorsque les expressions allow dans l'instruction de correspondance sont évaluées, la résolution de la variable city donne le nom de document de ville, tel que SF ou NYC.

Sous-collections correspondantes

Les données de Cloud Firestore sont organisées en collections de documents, et chaque document peut étendre la hiérarchie via des sous-collections. Il est important de comprendre comment les règles de sécurité interagissent avec les données hiérarchisées.

Examinons la situation dans laquelle chaque document de la collection cities contient une sous-collection landmarks. Les règles de sécurité ne s'appliquent qu'au chemin correspondant. Par conséquent, les contrôles d'accès définis dans la collection cities ne s'appliquent pas à la sous-collection landmarks. À la place, écrivez des règles explicites pour contrôler l'accès aux sous-collections :

service cloud.firestore {
  match /databases/{database}/documents {
    match /cities/{city} {
      allow read, write: if <condition>;

      // Explicitly define rules for the 'landmarks' subcollection
      match /landmarks/{landmark} {
        allow read, write: if <condition>;
      }
    }
  }
}

Lors de l'imbrication des instructions match, le chemin de l'instruction interne match est toujours relatif au chemin de l'instruction externe match. Les ensembles de règles suivants sont donc équivalents :

service cloud.firestore {
  match /databases/{database}/documents {
    match /cities/{city} {
      match /landmarks/{landmark} {
        allow read, write: if <condition>;
      }
    }
  }
}
service cloud.firestore {
  match /databases/{database}/documents {
    match /cities/{city}/landmarks/{landmark} {
      allow read, write: if <condition>;
    }
  }
}

Caractères génériques récursifs

Si vous souhaitez que les règles s'appliquent à une hiérarchie arbitrairement profonde, utilisez la syntaxe de caractères génériques récursifs, {name=**}:

service cloud.firestore {
  match /databases/{database}/documents {
    // Matches any document in the cities collection as well as any document
    // in a subcollection.
    match /cities/{document=**} {
      allow read, write: if <condition>;
    }
  }
}

Lorsque vous utilisez la syntaxe de caractères génériques récursifs, la variable générique contient l'intégralité du segment de chemin correspondant, même si le document se trouve dans une sous-collection profondément imbriquée. Par exemple, les règles répertoriées ci-dessus correspondent à un document situé à l'adresse /cities/SF/landmarks/coit_tower et la valeur de la variable document sera SF/landmarks/coit_tower.

Notez toutefois que le comportement des caractères génériques récursifs dépend de la version des règles.

Version 1

Les règles de sécurité utilisent la version 1 par défaut. Dans la version 1, les caractères génériques récursifs correspondent à un ou plusieurs éléments de chemin. Ils ne correspondent pas à un chemin vide, match /cities/{city}/{document=**} correspond donc aux documents des sous-collections, mais pas dans la collection cities alors que match /cities/{document=**} correspond aux documents de la collection cities et des sous-collections.

Les caractères génériques récursifs doivent apparaître à la fin d'une instruction de correspondance.

Version 2

Dans la version 2 des règles de sécurité, les caractères génériques récursifs peuvent correspondre à zéro, un ou plusieurs éléments de chemin. match/cities/{city}/{document=**} correspond aux documents des sous-collections, ainsi qu'aux documents de la collection cities.

Vous devez activer la version 2 en ajoutant rules_version = '2'; en haut de vos règles de sécurité :

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    // Matches any document in the cities collection as well as any document
    // in a subcollection.
    match /cities/{city}/{document=**} {
      allow read, write: if <condition>;
    }
  }
}

Vous pouvez utiliser au maximum un caractère générique récursif par requête, mais dans la version 2, vous pouvez placer ce caractère générique n'importe où dans l'instruction de correspondance. Exemple :

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    // Matches any document in the songs collection group
    match /{path=**}/songs/{song} {
      allow read, write: if <condition>;
    }
  }
}

Si vous utilisez des requêtes de groupes de collections, vous devez utiliser la version 2. Consultez la page Sécuriser les requêtes de groupes de collections.

Chevauchement d'instructions de correspondance

Il est possible qu'un document corresponde à plusieurs instructions match. Dans le cas où plusieurs expressions allow correspondent à une requête, l'accès est autorisé si au moins une des conditions est définie sur true :

service cloud.firestore {
  match /databases/{database}/documents {
    // Matches any document in the 'cities' collection.
    match /cities/{city} {
      allow read, write: if false;
    }

    // Matches any document in the 'cities' collection or subcollections.
    match /cities/{document=**} {
      allow read, write: if true;
    }
  }
}

Dans l'exemple ci-dessus, toutes les lectures et écritures dans la collection cities seront autorisées, car la seconde règle est toujours true, même si la première règle est toujours false.

Limites des règles de sécurité

Lorsque vous utilisez des règles de sécurité, tenez compte des limites suivantes :

Limite Détails
Nombre maximal d'appels de méthode exists(), get() et getAfter() par requête
  • 10 pour les requêtes de documents uniques et les requêtes de type "query".
  • 20 pour les lectures de plusieurs documents, les transactions et les écritures par lot. La limite de 10 précédente s'applique également à chaque opération.

    Par exemple, imaginons que vous créez une requête d'écriture par lot comprenant trois opérations, et que vos règles de sécurité utilisent deux appels d'accès au document pour valider chaque écriture. Dans ce cas, chaque écriture utilise deux de ses 10 appels d'accès et la requête d'écriture par lot utilise six de ses 20 appels d'accès.

Le dépassement de l'une ou l'autre limite entraîne une erreur de type "permission refusée".

Certains appels d'accès aux documents peuvent être mis en cache, et les appels en cache ne sont pas pris en compte dans les limites.

Profondeur maximale d'instructions match imbriquées 10
Longueur maximale du chemin, en segments de chemin, autorisée dans un ensemble d'instructions match imbriquées 100
Nombre maximal de variables de capture de chemin autorisées dans un ensemble d'instructions match imbriquées 20
Profondeur maximale des appels de fonction 20
Nombre maximal d'arguments de fonction 7
Nombre maximal de liaisons de variables let par fonction 10
Nombre maximal d'appels de fonction récursifs ou cycliques 0 (non autorisé)
Nombre maximal d'expressions évaluées par requête 1 000
Taille maximale d'un ensemble de règles Les ensembles de règles doivent respecter deux limites de taille :
  • une limite de 256 Ko applicable à la taille du texte source de l'ensemble de règles publié à partir de la console Firebase ou de la CLI à l'aide de firebase deploy.
  • une limite de 250 Ko applicable à la taille de l'ensemble de règles compilé qui apparaît lorsque Firebase traite la source et l'active sur le backend.

Cloud Storage

Structure de base

Firebase Security Rules dans Cloud Firestore et Cloud Storage utilise la structure et la syntaxe suivantes:

service <<name>> {
  // Match the resource path.
  match <<path>> {
    // Allow the request if the following conditions are true.
    allow <<methods>> : if <<condition>>
  }
}

Vous devez comprendre les concepts clés suivants lorsque vous créez des règles:

  • Request (Requête) : méthode ou méthodes appelées dans l'instruction allow. Il s'agit des méthodes que vous autorisez à s'exécuter. Les méthodes standards sont les suivantes: get, list, create, update et delete. Les méthodes pratiques read et write permettent un accès en lecture et en écriture étendu sur la base de données ou le chemin d'accès au stockage spécifié.
  • Chemin:emplacement de la base de données ou de l'emplacement de stockage, représenté par un chemin d'URI.
  • Règle:instruction allow, qui inclut une condition qui autorise une requête si elle renvoie la valeur "true".

Chemins correspondants

Cloud Storage Security Rules match les chemins d'accès aux fichiers utilisés pour accéder aux fichiers dans Cloud Storage. Les règles peuvent match des chemins d'accès exacts ou des chemins d'accès avec des caractères génériques, et elles peuvent également être imbriquées. Si aucune règle de correspondance n'autorise une méthode de requête ou si la condition renvoie false, la requête est refusée.

Correspondances exactes

// Exact match for "images/profilePhoto.png"
match /images/profilePhoto.png {
  allow write: if <condition>;
}

// Exact match for "images/croppedProfilePhoto.png"
match /images/croppedProfilePhoto.png {
  allow write: if <other_condition>;
}

Correspondances imbriquées

// Partial match for files that start with "images"
match /images {
  // Exact match for "images/profilePhoto.png"
  match /profilePhoto.png {
    allow write: if <condition>;
  }

  // Exact match for "images/croppedProfilePhoto.png"
  match /croppedProfilePhoto.png {
    allow write: if <other_condition>;
  }
}

Correspondances avec des caractères génériques

Les règles peuvent également être utilisées pour match un format à l'aide de caractères génériques. Un caractère générique est une variable nommée qui représente une seule chaîne, comme profilePhoto.png, ou plusieurs segments de chemin, comme images/profilePhoto.png.

Pour créer un caractère générique, ajoutez des accolades autour de son nom, par exemple {string}. Vous pouvez déclarer un caractère générique multisegment en ajoutant =** au nom du caractère générique, par exemple {path=**}:

// Partial match for files that start with "images"
match /images {
  // Exact match for "images/*"
  // e.g. images/profilePhoto.png is matched
  match /{imageId} {
    // This rule only matches a single path segment (*)
    // imageId is a string that contains the specific segment matched
    allow read: if <condition>;
  }

  // Exact match for "images/**"
  // e.g. images/users/user:12345/profilePhoto.png is matched
  // images/profilePhoto.png is also matched!
  match /{allImages=**} {
    // This rule matches one or more path segments (**)
    // allImages is a path that contains all segments matched
    allow read: if <other_condition>;
  }
}

Si plusieurs règles correspondent à un fichier, le résultat est le OR du résultat de toutes les évaluations de règles. Autrement dit, si une règle à laquelle le fichier correspond est évaluée à true, le résultat est true.

Dans les règles ci-dessus, le fichier "images/profilePhoto.png" peut être lu si condition ou other_condition renvoie la valeur "true", tandis que le fichier "images/users/user:12345/profilePhoto.png" n'est soumis qu'au résultat de other_condition.

Une variable générique peut être référencée dans l'autorisation de nom ou de chemin d'accès du fichier match:

// Another way to restrict the name of a file
match /images/{imageId} {
  allow read: if imageId == "profilePhoto.png";
}

Les Cloud Storage Security Rules ne sont pas en cascade, et les règles ne sont évaluées que lorsque le chemin de requête correspond à un chemin avec des règles spécifiées.

Demander une évaluation

Les importations, les téléchargements, les modifications de métadonnées et les suppressions sont évalués à l'aide de l'request envoyé à Cloud Storage. La variable request contient le chemin d'accès au fichier où la requête est effectuée, l'heure à laquelle la requête est reçue et la nouvelle valeur resource si la requête est une écriture. Les en-têtes HTTP et l'état d'authentification sont également inclus.

L'objet request contient également l'ID unique de l'utilisateur et la charge utile Firebase Authentication dans l'objet request.auth, qui sera expliquée plus en détail dans la section Authentification de la documentation.

Vous trouverez ci-dessous la liste complète des propriétés de l'objet request:

Propriété Type Description
auth map<chaîne, chaîne> Lorsqu'un utilisateur est connecté, fournit uid, l'ID unique de l'utilisateur, et token, une carte des revendications JWT Firebase Authentication. Sinon, il est défini sur null.
params map<chaîne, chaîne> Carte contenant les paramètres de requête de la requête.
path chemin d'accès path représentant le chemin d'accès de la requête.
resource map<chaîne, chaîne> Nouvelle valeur de la ressource, présente uniquement dans les requêtes write.
time timestamp Code temporel représentant l'heure du serveur à laquelle la requête est évaluée.

Évaluation des ressources

Lorsque vous évaluez des règles, vous pouvez également évaluer les métadonnées du fichier importé, téléchargé, modifié ou supprimé. Vous pouvez ainsi créer des règles complexes et puissantes qui, par exemple, n'autorisent que l'importation de fichiers de certains types de contenu ou la suppression de fichiers de plus d'une certaine taille.

Firebase Security Rules pour Cloud Storage fournit des métadonnées de fichier dans l'objet resource, qui contient des paires clé/valeur des métadonnées affichées dans un objet Cloud Storage. Ces propriétés peuvent être inspectées sur les requêtes read ou write pour garantir l'intégrité des données.

Pour les requêtes write (telles que les importations, les mises à jour de métadonnées et les suppressions), en plus de l'objet resource, qui contient les métadonnées du fichier existant actuellement sur le chemin de la requête, vous pouvez également utiliser l'objet request.resource, qui contient un sous-ensemble des métadonnées du fichier à écrire si l'écriture est autorisée. Vous pouvez utiliser ces deux valeurs pour garantir l'intégrité des données ou appliquer des contraintes d'application telles que le type ou la taille de fichier.

Vous trouverez ci-dessous la liste complète des propriétés de l'objet resource:

Propriété Type Description
name chaîne Nom complet de l'objet
bucket chaîne Nom du bucket dans lequel se trouve cet objet.
generation int Génération de l'objet Google Cloud Storage de cet objet.
metageneration int Métagénération de l'objet Google Cloud Storage de cet objet.
size int La taille de l'objet en octets.
timeCreated timestamp Code temporel représentant l'heure de création d'un objet.
updated timestamp Code temporel représentant l'heure de la dernière mise à jour d'un objet.
md5Hash chaîne Hachage MD5 de l'objet.
crc32c chaîne Un hachage CRC32C de l'objet.
etag chaîne Etag associé à cet objet.
contentDisposition chaîne Disposition du contenu associée à cet objet.
contentEncoding chaîne Encodage de contenu associé à cet objet.
contentLanguage chaîne Langue du contenu associé à cet objet.
contentType chaîne Type de contenu associé à cet objet.
metadata map<chaîne, chaîne> Paires clé/valeur de métadonnées personnalisées supplémentaires spécifiées par le développeur.

request.resource contient tous ces éléments, à l'exception de generation, metageneration, etag, timeCreated et updated.

Limites des règles de sécurité

Lorsque vous utilisez des règles de sécurité, tenez compte des limites suivantes :

Limite Détails
Nombre maximal d'appels firestore.exists() et firestore.get() par requête

2 pour les requêtes de documents uniques et les requêtes de type "query".

Le dépassement de cette limite entraîne une erreur de type "permission refusée".

Les appels d'accès aux mêmes documents peuvent être mis en cache, et les appels en cache ne sont pas pris en compte dans les limites.

Exemple complet

En rassemblant toutes ces informations, vous pouvez créer un exemple complet de règles pour une solution de stockage d'images:

service firebase.storage {
 match /b/{bucket}/o {
   match /images {
     // Cascade read to any image type at any path
     match /{allImages=**} {
       allow read;
     }

     // Allow write files to the path "images/*", subject to the constraints:
     // 1) File is less than 5MB
     // 2) Content type is an image
     // 3) Uploaded content type matches existing content type
     // 4) File name (stored in imageId wildcard variable) is less than 32 characters
     match /{imageId} {
       allow write: if request.resource.size < 5 * 1024 * 1024
                    && request.resource.contentType.matches('image/.*')
                    && request.resource.contentType == resource.contentType
                    && imageId.size() < 32
     }
   }
 }
}

Realtime Database

Structure de base

Dans Realtime Database, Firebase Security Rules se compose d'expressions semblables à JavaScript contenues dans un document JSON.

Elles utilisent la syntaxe suivante:

{
  "rules": {
    "<<path>>": {
    // Allow the request if the condition for each method is true.
      ".read": <<condition>>,
      ".write": <<condition>>,
      ".validate": <<condition>>
    }
  }
}

La règle comporte trois éléments de base:

  • Chemin:emplacement de la base de données. Cela reflète la structure JSON de votre base de données.
  • Request (Requête) : méthodes utilisées par la règle pour accorder l'accès. Les règles read et write accordent un accès en lecture et en écriture étendu, tandis que les règles validate servent de vérification secondaire pour accorder l'accès en fonction des données entrantes ou existantes.
  • Condition:condition qui autorise une requête si elle renvoie la valeur "true".

Application des règles aux chemins

Dans Realtime Database, Rules s'applique de manière atomique, ce qui signifie que les règles au niveau des nœuds parent de niveau supérieur remplacent les règles au niveau des nœuds enfants plus précis, et que les règles au niveau d'un nœud plus profond ne peuvent pas accorder l'accès à un chemin parent. Vous ne pouvez pas affiner ni révoquer l'accès à un chemin plus profond dans la structure de votre base de données si vous l'avez déjà accordé pour l'un des chemins parents.

Tenez compte des règles suivantes:

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

Cette structure de sécurité permet de lire /bar/ chaque fois que /foo/ contient un baz enfant avec la valeur true. La règle ".read": false sous /foo/bar/ n'a aucun effet ici, car l'accès ne peut pas être révoqué par un chemin d'accès enfant.

Bien que cela puisse ne pas sembler immédiatement intuitif, il s'agit d'une partie puissante du langage de règles et permet d'implémenter des droits d'accès très complexes avec un effort minimal. Cela est particulièrement utile pour la sécurité basée sur l'utilisateur.

Toutefois, les règles .validate ne se répercutent pas. Toutes les règles de validation doivent être respectées à tous les niveaux de la hiérarchie pour qu'une écriture soit autorisée.

De plus, comme les règles ne s'appliquent pas à un chemin parent, les opérations de lecture ou d'écriture échouent si aucune règle n'est appliquée à l'emplacement demandé ou à un emplacement parent qui accorde l'accès. Même si tous les chemins d'accès enfant concernés sont accessibles, la lecture à l'emplacement parent échoue complètement. Prenons cette structure:

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

Sans comprendre que les règles sont évaluées de manière atomique, il peut sembler que l'extraction du chemin /records/ renvoie rec1, mais pas rec2. Cependant, le résultat réel est une erreur:

JavaScript
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
Remarque : Ce produit Firebase n'est pas disponible sur la cible App Clip.
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
Remarque : Ce produit Firebase n'est pas disponible sur la cible App Clip.
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

Étant donné que l'opération de lecture à /records/ est atomique et qu'aucune règle de lecture n'accorde l'accès à toutes les données sous /records/, une erreur PERMISSION_DENIED est générée. Si nous évaluons cette règle dans le simulateur de sécurité de la console Firebase, nous pouvons voir que l'opération de lecture a été refusée:

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

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

L'opération a été refusée, car aucune règle de lecture n'autorisait l'accès au chemin d'accès /records/. Notez toutefois que la règle pour rec1 n'a jamais été évaluée, car elle ne figurait pas dans le chemin d'accès que nous avons demandé. Pour extraire rec1, nous devons y accéder directement:

JavaScript
var db = firebase.database();
db.ref("records/rec1").once("value", function(snap) {
  // SUCCESS!
}, function(err) {
  // error callback is not called
});
Objective-C
Remarque : Ce produit Firebase n'est pas disponible sur la cible App Clip.
FIRDatabaseReference *ref = [[FIRDatabase database] reference];
[[ref child:@"records/rec1"] observeSingleEventOfType:FEventTypeValue withBlock:^(FIRDataSnapshot *snapshot) {
    // SUCCESS!
}];
Swift
Remarque : Ce produit Firebase n'est pas disponible sur la cible App Clip.
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!

Variable de lieu

Realtime Database Rules accepte une variable $location pour faire correspondre des segments de chemin d'accès. Utilisez le préfixe $ devant votre segment de chemin d'accès pour faire correspondre votre règle à tous les nœuds enfants le long du chemin d'accès.

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

Vous pouvez également utiliser $variable en parallèle avec des noms de chemin d'accès constants.

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