Home > Software engineering >  Firestore rule fails in the rules playground (as expected) but passes in my live site
Firestore rule fails in the rules playground (as expected) but passes in my live site

Time:04-01

I have the following Firestore database rules. I have categories that have a 'private' boolean field, as well as a userId. These are the conditions I want to enforce:

  1. If a category is private and owned by a user (i.e., same userId as the userId on the category document), they can read it.
  2. If a category is private and not owned by a user (userId does not match), then they cannot read it.

In the rules playground, when I attempt to access a category document at the document path categories/{id} for a category that has the private field set to true, it correctly denies access. However, when I read the same category as a user that doesn't own the category in my live application, the rule allows it. I'm stumped.

rules_version = '2';

function fieldExists(data, field) {
  return !isUndefined(data, field) && !isNull(data, field);
}

function isUndefined(data, field) {
  return !data.keys().hasAll([field]);
}

function isNull(data, field) {
  return data[field] == null;
}

function canView(resource, auth) {
    return resource.data.private == true && isOwner(resource, auth);
}

function isPublic(resource) {
  return !fieldExists(resource.data, 'private') || resource.data.private != true;
}

function isAuthenticated(request) {
    return request.auth != null;
}

function isOwner(resource, auth) {
    return resource.data.userId == auth.uid;
}

service cloud.firestore {
  match /databases/{database}/documents {
    match /categories/{categoryId} {
      allow create: if isAuthenticated(request);
      allow update, delete: if isAuthenticated(request) && isOwner(resource, request.auth);
      allow read: if isAuthenticated(request) &&
                            (isPublic(resource) || canView(resource, request.auth));
    }
  }
}

I'm making two different requests for the category. One is on a list page that retrieves many categories, and the other is on a view page that retrieves a single category.

import {
  collection,
  query,
  getDocs,
  where,
  limit,
} from "firebase/firestore/lite"; // ^9.5.0
import _ from 'lodash'; // ^4.17.21
 
// Some helper methods
export async function getCollection(collectionName) {
  return await collection(db, collectionName);
}

export async function getCollectionRef(collectionName) {
  return await getCollection(collectionName);
}

export async function runQuery(q) {
  const querySnapshot = await getDocs(q);
  if (_.isEmpty(querySnapshot)) {
    return null;
  }

  return querySnapshot.docs.map((doc) => doc.data());
}

// The query method to look up the category by id for the 'view' page
export async function queryById(collectionName, id) {
  const collectionRef = await getCollectionRef(collectionName);
  const q = query(collectionRef, where("id", "==", id), limit(1));
  const result = await runQuery(q);
  return result[0];
}

// I'm using this batch method to collect all the categories for the 'list' page based on an array of category ids
export async function queryByIds(collectionName, ids) {
  const collectionRef = await getCollectionRef(collectionName);
  const batches = [];

  if (!ids || !ids.length) {
    return [];
  }

  let arrayLength = ids.length;
  let currentIndex = 0;
  while (currentIndex <= arrayLength) {
    const batch = ids.slice(currentIndex, currentIndex   10);
    if (!batch.length) {
      break;
    }
    const q = query(collectionRef, where("id", "in", batch));
    batches.push(runQuery(q));
    currentIndex  = 10;
  }

  return await (await Promise.all(batches)).flat();
}

If I view the 'private' category as a non-owner of the category I can still see the category data. I can also view the category in the list of categories as that user (when viewing another user's categories). I would expect that the 'private' category would not be included in the list of categories and if viewing it directly its data would not be present to the system. Note: I'm using Vue and when I route to the category page I request the category document from the id which is in the route params.

These rules work as expected in the rules playground but not in live situations, and I can't figure out why. Any help would be greatly appreciated.

UPDATE: My Firestore Data Structure is as follows:

- users
- categories
  - category: {
    id: String
    name: String
    userId: String
    private: Boolean
    createdAt: Timestamp
    updatedAt: Timestamp
    notes: Array (of ids)
    ...additional data fields
  }
- notes
- photos

The other top level root collections have the same rules applied to them in the database rules as do categories. For example:


    match /users/{userId} {
        allow create: if isAuthenticated(request);
      allow update, delete: if isAuthenticated(request) && isOwner(resource, request.auth);
      allow read: if isAuthenticated(request) &&
                            (isPublic(resource) || canView(resource, request.auth));
    }

I don't think they are affecting this issue since I am not querying them in this case.

CodePudding user response:

You have a double set of && in your read rule:

allow read: if isAuthenticated(request) &&
            && (isPublic(resource) || canView(resource, request.auth));

Try removing one set of those &&. It might be causing the weirdness?

allow read: if isAuthenticated(request) &&
            (isPublic(resource) || canView(resource, request.auth));

CodePudding user response:

Cloud Firestore Security Rules only load the fields from the document which are included in the query. If you don't include it in a query they're guaranteed to fail (as they will be tested against something undefined).

Cloud Firestore Security Rules don't filter data by themselves. They merely enforce that read operations only access documents that are allowed. You'll need to add where("private", "==", true) to your query to make it match with your rules. See code below:

const q = query(collectionRef, where("id", "==", id), where("private", "==", true), limit(1));

Just a note: This does require that the document has a private field, otherwise the private can't be empty. You should add the private field on all the documents. You can leave your security rules as it is just to enforce that all documents have the private field.

As you trigger the query above, the console will require you to create an index through the console's error message. Error message will look like this:

[FirebaseError: The query requires an index. You can create it here: https://console.firebase.google.com/v1/r/project/<PROJECT-ID>/firestore/indexes?create_composite=ClRwcm9qZWN0cy90aXBoLW1hcmNhbnRob255Yi9kYXRhYmFzZXMvKGRlZmF1bHQpL2NvbGxlY3Rpb25Hcm91cHMvY2F0ZWdvcmllcy9pbmRleGVzL18QARoKCgZ1c2VySWQQARoLCgdwcml2YXRlEAEaDAoIX19uYW1lX18QAQ] {
  code: 'failed-precondition',
  customData: undefined,
  toString: [Function (anonymous)]
}

You can find more relevant information on these documentation:

  • Related