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]) + ) + ) + ); + }); +});