Skip to content

Scope the checkins collection group, and serve pet history from a callable - #190

Open
renrenmimi wants to merge 1 commit into
mainfrom
fix/checkins-collection-group-lockdown
Open

Scope the checkins collection group, and serve pet history from a callable#190
renrenmimi wants to merge 1 commit into
mainfrom
fix/checkins-collection-group-lockdown

Conversation

@renrenmimi

Copy link
Copy Markdown
Owner

collectionGroup('checkins').where('userId','==',victim).orderBy('createdAt') returned a person's movement timeline — every place they have physically been, with timestamps — to an unauthenticated caller with no rate limit. Both the (userId, createdAt) and (petId, createdAt) collection-group indexes exist, so this was servable today.

⚠️ The brief was wrong about one thing, and following it would have broken a live feature

Delete the getCheckinsByUser dead code — it has no callers.

The function is getUserCheckins, and it is not dead: Profile.tsx:130 calls getUserCheckins(user.uid) to render the signed-in user's own check-ins tab.

Deleting it, or closing the rule outright with allow read: if false as the brief implied, would have removed a user's ability to see their own check-in history.

So the rule is scoped to the requesting user rather than closed:

allow read: if isAuthenticated() && resource.data.userId == request.auth.uid;

This still closes both harvesting paths. The brief's core point was right — tightening only userId is theatre, since pets/{petId} is world-readable and carries ownerId — but scoping achieves it anyway: a petId-filtered collection-group query can return documents belonging to other people, so Firestore refuses it outright. There's a test pinning exactly that, including that even the pet's own owner is refused.

The pet profile

Moves to getPetCheckinsCallable. Still public, no login — check-ins are content the user chose to publish and that page has always shown them. The exposure was never visibility, it was bulk harvesting with no ceiling. So:

  • rows capped at 100 regardless of what's asked for
  • assertRateLimit, bucketed by uid when signed in and by client IP when not, so an anonymous scraper can't get an unlimited budget by staying logged out
  • no userId / userName / userAvatar in the response. The page never rendered them, and leaving them out means this route can't be reassembled into a per-person lookup either

locations/{id}/checkins is untouched and still world-readable — that's a per-place question, not a per-person one.

Tests

Rules, 6 new — 4 fail on the old rule:

× refuses a query for someone else's history
× refuses an unauthenticated query for anyone's history
× refuses the petId route, which is the same leak one hop away
× refuses an unfiltered collection-group scan

Two pass on both, as guardrails: an unauthenticated read of one location's check-ins, and a user querying their own history — the Profile tab the brief would have broken.

Callable, 8 new: the public path, newest-first ordering, the row cap under an absurd limitCount, the absent identity fields (asserted both by key and by scanning the serialised response for the uid), cross-pet isolation, requiredDocId validation, and the empty case.

Local: rules 74/74, functions test:emulator 88/88, typecheck:test / typecheck:tests / both builds clean.

Deploy

Rules first, then functions — the client change depends on the callable existing.

firebase deploy --only firestore:rules
firebase deploy --only functions

Noted separately, not bundled: the deletion cascade does not clean up locations a user added.

🤖 Generated with Claude Code

…lable

`collectionGroup('checkins').where('userId','==',victim).orderBy('createdAt')`
returned a person's movement timeline — every place they have physically been,
with timestamps — to an unauthenticated caller with no rate limit. Both the
(userId, createdAt) and (petId, createdAt) collection-group indexes exist, so
this was servable, not theoretical.

Closing only the userId path would have been theatre, and the brief said so:
pets/{petId} is world-readable and carries ownerId, so the same timeline was
one hop away through petId. Scoping the rule to the requesting user closes
both, because a petId-filtered collection-group query can return documents
belonging to other people and Firestore refuses it outright.

THE BRIEF WAS WRONG ABOUT ONE THING, and following it would have broken a live
feature. It said to delete getUserCheckins as dead code with no callers. The
function is actually getUserCheckins, and src/pages/Profile.tsx:130 calls it
with the signed-in user's own uid to render their own check-ins tab. It is
kept, and the rule is scoped rather than closed outright so that query keeps
working — routing a user's own data through a callable would buy nothing.

The pet profile moves to getPetCheckinsCallable, which stays public with no
login, because check-ins are content the user chose to publish and that page
has always shown them. The exposure was never visibility, it was bulk
harvesting with no ceiling. So the callable caps rows at 100 and runs through
assertRateLimit, bucketed by uid when signed in and by client IP when not, so
an anonymous scraper cannot get an unlimited budget by staying logged out.

The response deliberately carries no userId / userName / userAvatar. The pet
page never rendered them, and leaving them out means this route cannot be
reassembled into a per-person lookup either.

The per-location view (locations/{id}/checkins) is untouched and still
world-readable. That is a per-place question, not a per-person one.

Six new rules tests, four of which fail on the old rule; the two that pass on
both are the guardrails — an unauthenticated read of one location's check-ins,
and a user querying their own history. Eight new callable tests covering the
public path, ordering, the row cap, the absent identity fields, cross-pet
isolation and docId validation.

Rules 74/74, functions 88/88.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Copilot AI lite review requested due to automatic review settings September 6, 2026 22:13

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@vercel

vercel Bot commented Sep 6, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
pet-note Ready Ready Preview Sep 6, 2026 10:13pm UTC

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: e538be5d71

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread functions/src/places.ts
Comment on lines +891 to +895
const forwardedFor = request.rawRequest?.headers?.["x-forwarded-for"];
const rawIp =
(Array.isArray(forwardedFor) ? forwardedFor[0] : forwardedFor)
?.split(",")[0]
?.trim() ||

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Derive anonymous rate-limit buckets from a trusted IP

An unauthenticated caller invoking the callable directly can supply an arbitrary leading X-Forwarded-For value; Google’s proxy may append forwarding information, but this code always selects the first entry. Rotating that value creates a fresh Firestore rate-limit document for every request, bypassing the intended 120-request ceiling and allowing the pet-history scraping this change is meant to prevent. Use a platform-derived address or parse only the trusted proxy hop instead.

Useful? React with 👍 / 👎.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants