From a640c44ae2afbaaa65a808347c6a2941a6a300fb Mon Sep 17 00:00:00 2001 From: WEIREN FENG Date: Thu, 3 Sep 2026 17:45:48 -0700 Subject: [PATCH] Scope the likes collection group to the requesting user MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `collectionGroup('likes').where('userId','==',victim)` returned anyone's complete like history — every post they have ever liked, in one query, to an unauthenticated caller. The composite index exists, so it was servable today. Per-post likes stay world-readable and that is deliberate: a post's like count and its likers are public, and that is the only shape any UI needs. The collection group answers a different question — "every like matching a filter, across all posts" — and no UI has ever exposed that aggregate. "These 40 accounts liked this photo" and "here is everything this person has ever liked" are different facts about someone. The only client that uses the collection group is useBatchLikeStatus, which already filters by the signed-in user's own uid, so it is unaffected. There is a test pinning exactly its query shape (userId == me AND postId in [...]) so this cannot be tightened further by accident and silently strip like state off every feed card. Three new tests fail on the old rule and pass on this one: the unauthenticated cross-post query, a signed-in user reading someone else's history, and an unfiltered scan. Two more pass on both, as guardrails: an unauthenticated read of a single post's likes, and the batched own-likes query. Checkins were reported alongside this and are NOT changed here — the same tightening would break the public per-pet check-in history on PetProfile, and constraining only the userId path would not close the exposure anyway. Raised separately rather than half-fixed. Co-Authored-By: Claude Opus 5 (1M context) --- firestore.rules | 17 +++++- tests/rules/ban-and-engagement.test.ts | 80 +++++++++++++++++++++++++- 2 files changed, 95 insertions(+), 2 deletions(-) diff --git a/firestore.rules b/firestore.rules index 96a09dc..782c172 100644 --- a/firestore.rules +++ b/firestore.rules @@ -515,8 +515,23 @@ service cloud.firestore { allow read: if true; } + // Scoped to the requesting user, unlike the per-post rule above. + // + // posts/{postId}/likes stays world-readable on purpose — a post's like + // count and its likers are public, and that is the only shape any UI + // needs. This collection-group rule is a different question: it answers + // "give me every like matching a filter, across all posts", and the only + // client that uses it (useBatchLikeStatus) already filters by the signed-in + // user's own uid. + // + // Left open, `collectionGroup('likes').where('userId','==',victim)` returned + // anyone's complete like history to an unauthenticated caller — every post + // they have ever liked, in one query, keyed to a person. Per-post likes + // being public does not make that aggregate public: no UI has ever exposed + // it, and it is a different fact about someone than "these 40 accounts + // liked this photo". match /{path=**}/likes/{likeId} { - allow read: if true; + allow read: if isAuthenticated() && resource.data.userId == request.auth.uid; } match /{path=**}/comments/{commentId} { diff --git a/tests/rules/ban-and-engagement.test.ts b/tests/rules/ban-and-engagement.test.ts index 7d18524..c2b6e92 100644 --- a/tests/rules/ban-and-engagement.test.ts +++ b/tests/rules/ban-and-engagement.test.ts @@ -4,7 +4,18 @@ import { assertSucceeds, type RulesTestEnvironment, } from "@firebase/rules-unit-testing"; -import { doc, deleteDoc, serverTimestamp, setDoc } from "firebase/firestore"; +import { + collection, + collectionGroup, + doc, + deleteDoc, + getDoc, + getDocs, + query, + serverTimestamp, + setDoc, + where, +} from "firebase/firestore"; import { bannedUser, makeTestEnv, plainUser } from "./env"; // Everything a client may write directly runs through isNotBanned() and @@ -390,3 +401,70 @@ describe("backend-only collections are closed to clients", () => { ); }); }); + +describe("the likes collection group is scoped to the requesting user", () => { + // posts/{postId}/likes is world-readable and stays that way — a post's + // likers are public. The collection group answers a different question: + // "every like matching a filter, across all posts". Left open, that turned + // any uid into a complete, queryable like history for an unauthenticated + // caller. No UI has ever exposed that aggregate. + beforeEach(async () => { + await env.withSecurityRulesDisabled(async (ctx) => { + const db = ctx.firestore(); + for (const uid of [ALICE, BANNED]) { + await setDoc(doc(db, `posts/${POST}/likes/${uid}`), { + userId: uid, + postId: POST, + createdAt: new Date(), + counted: true, + }); + } + }); + }); + + it("still lets anyone read a single post's likes", async () => { + // The public half. An unauthenticated visitor sees who liked a post. + const db = env.unauthenticatedContext().firestore(); + await assertSucceeds(getDoc(doc(db, `posts/${POST}/likes/${ALICE}`))); + await assertSucceeds(getDocs(collection(db, `posts/${POST}/likes`))); + }); + + it("refuses an unauthenticated cross-post query for one person's likes", async () => { + const db = env.unauthenticatedContext().firestore(); + await assertFails( + getDocs( + query(collectionGroup(db, "likes"), where("userId", "==", ALICE)) + ) + ); + }); + + it("refuses a signed-in user querying someone else's like history", async () => { + const db = plainUser(env, BANNED).firestore(); + await assertFails( + getDocs( + query(collectionGroup(db, "likes"), where("userId", "==", ALICE)) + ) + ); + }); + + it("refuses an unfiltered collection-group scan", async () => { + const db = plainUser(env, ALICE).firestore(); + await assertFails(getDocs(collectionGroup(db, "likes"))); + }); + + it("still serves the batched own-likes query the feed depends on", async () => { + // useBatchLikeStatus: where(userId == me) + where(postId in [...]). + // This is the only collection-group likes query in the client, and it + // must keep working or every feed card loses its like state. + const db = plainUser(env, ALICE).firestore(); + await assertSucceeds( + getDocs( + query( + collectionGroup(db, "likes"), + where("userId", "==", ALICE), + where("postId", "in", [POST]) + ) + ) + ); + }); +});