Skip to content

fix: stop the client from awarding itself points - #19

Merged
duuyguyiilmaz merged 1 commit into
mainfrom
fix/points-cannot-be-forged
Aug 31, 2026
Merged

duuyguyiilmaz merged 1 commit into
mainfrom
fix/points-cannot-be-forged

Conversation

@duuyguyiilmaz

Copy link
Copy Markdown
Owner

The README described this as "a malicious client could still award itself points", which understated it. There were two holes and neither needed a forged rating.

  • allow update: if isOwner(userId) let a user write any field of their own document, so points: 999999 was a one-line request.
  • Any signed-in user could set any note's ratingSum to any non-negative value — the number both the leaderboard and the profile total are read from.

The fix turned out to be mostly deletion

users.points is never read. ProfileViewModel already computes the total as posts.sumOf { it.ratingSum } over the user's own notes. The field existed only to be written — and storing it is what forced the rules to let one user write another user's document.

Removing it removes the cross-user write from the rating transaction, the clause that permitted it, and RatingCalculator.pointsDelta, which had no other caller. Nothing changes on screen.

It is the same reasoning already applied to the contribution gate: a value derived from the notes cannot drift from them, and cannot be forged separately from them.

The user document is now allow update: if false, matching what the app does — saveUser is called once, during registration, and never again.

What could not be deleted is verified instead

A vote writes the note and the rating document in one atomic commit, and the note's rule uses getAfter() to read the rating being written in that same commit, recompute ratingSum and ratingCount from it, and reject anything that does not match.

So now:

  • totals cannot move without a vote behind them
  • a changed vote cannot be counted twice
  • a note cannot be created with a score
  • an owner cannot raise their own note while editing it
  • rating your own note is refused by the server, not only by the client

The expected totals are RatingCalculator's arithmetic restated, floors included, because client and rule must agree exactly. avgRating is range-checked rather than compared: rules do integer division, and it is a derived display field whose inputs are pinned. math.max does not exist in rules, hence the ternaries.

A test that was passing for the wrong reason

Six new rule tests — and one existing test got sharper for a reason worth recording.

Weakening the ratingSum check to >= 0 failed to turn anything red. The old assertion used a sum of 500, which was already rejected by the avgRating range check, so it passed without ever exercising the arithmetic. Rewritten with values that sit inside every range check, it fails on the arithmetic alone.

Re-running the same mutation now turns three tests red: that one, and the two covering the user document.

Verification

Firestore suite: 38 rules tests, 46 total. Android suite: 107. assembleDebug and lintDebug pass.

🤖 Generated with Claude Code

The README described this as "a malicious client could still award itself points",
which understated it. There were two holes and neither needed a forged rating.

`allow update: if isOwner(userId)` let a user write any field of their own document,
so `points: 999999` was a one-line request. And any signed-in user could set any note's
`ratingSum` to any non-negative value, which is the number both the leaderboard and the
profile total are read from.

The fix turned out to be mostly deletion. `users.points` is never read: ProfileViewModel
already computes the total as `posts.sumOf { it.ratingSum }` over the user's own notes.
The field existed only to be written — and storing it is what forced the rules to let
one user write another user's document. Removing it removes the cross-user write from
the rating transaction, the clause that permitted it, and `RatingCalculator.pointsDelta`,
which had no other caller. Nothing changes on screen. It is the same reasoning already
applied to the contribution gate: a value derived from the notes cannot drift from them,
and cannot be forged separately from them.

The user document is now `allow update: if false`, matching what the app does — saveUser
is called once, during registration, and never again.

What could not be deleted is the note's own totals, so those are verified instead. A vote
writes the note and the rating document in one atomic commit, and the note's rule uses
getAfter() to read the rating being written in that same commit, recompute ratingSum and
ratingCount from it, and reject anything that does not match. Totals cannot move without
a vote behind them, a changed vote cannot be counted twice, a note cannot be created with
a score, an owner cannot raise their own note while editing it, and rating your own note
is now refused by the server rather than only by the client.

The expected totals are RatingCalculator's arithmetic restated, floors included, because
client and rule must agree exactly. avgRating is range-checked rather than compared:
rules do integer division, and it is a derived display field whose inputs are pinned.
math.max does not exist in rules, hence the ternaries.

Six new rule tests, and one existing test got sharper for a reason worth recording.
Weakening the ratingSum check to `>= 0` failed to turn anything red: the old assertion
used a sum of 500, which was already rejected by the avgRating range check, so it passed
for the wrong reason. Rewritten with values that sit inside every range check, it fails
on the arithmetic alone. Re-running the mutation now turns three tests red — that one,
and the two covering the user document.

Firestore suite: 38 rules tests, 46 total. Android suite: 107.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@duuyguyiilmaz
duuyguyiilmaz merged commit 8d3ef44 into main Aug 31, 2026
2 checks passed
@duuyguyiilmaz
duuyguyiilmaz deleted the fix/points-cannot-be-forged branch August 31, 2026 14:50
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.

1 participant