Demo, all content is generated
Question

Users can make themselves admin by editing their own Firestore document

Solved · 2302 views · asked by tessa_k · edited

A friend who does security poked at my app and showed me he could run this in the browser console while logged in as a normal user:

updateDoc(doc(db, "users", myUid), { role: "admin" })

and then he saw the admin dashboard. My rules:

match /users/{uid} {
  allow read, write: if request.auth.uid == uid;
}

Users need to be able to edit their name and avatar. How do I allow that but not role?

What I’ve tried

Cursor suggested hiding the admin link in the UI for non admins, which obviously doesn't fix it. Also tried moving role to a separate field name, lol.

Comment
Good friend. Most people find this out from someone less friendly. amir_h · edited
He's a very nice friend indeed :) tessa_k · edited

3 answers

Marked as helpful by the asker
sven_fire · edited

Restrict which fields a user may change with diff().affectedKeys():

match /users/{uid} {
  allow read: if request.auth.uid == uid;
  allow create: if request.auth.uid == uid
    && !("role" in request.resource.data);
  allow update: if request.auth.uid == uid
    && request.resource.data.diff(resource.data).affectedKeys()
         .hasOnly(["displayName", "avatarUrl"]);
}

Now any write touching role (or anything not in the list) is denied.

Better still, don't keep roles in a user-writable document at all. Set them as custom claims from the Admin SDK (setCustomUserClaims(uid, { admin: true })) and check request.auth.token.admin == true in rules. Claims can only be set server-side.

Comment
hasOnly is exactly what I needed. Moving to custom claims this weekend. tessa_k · edited
Checked my own app after reading this. Same hole. Thanks. hiro_t · edited
lena_ops · edited

Worth adding: your admin dashboard presumably reads data from other collections. Make sure those rules check the claim too, not just the page. Your friend could probably also read everything the admin page reads, directly.

Comment
...he could. fixing that too tessa_k · edited
amir_h · edited

Custom claims version, since that's the part people get stuck on:

// server / admin script, never in the client
import { getAuth } from "firebase-admin/auth";
await getAuth().setCustomUserClaims(uid, { admin: true });
allow read: if request.auth.token.admin == true;

The user's current ID token doesn't have the claim until it refreshes (up to an hour). Call user.getIdToken(true) on the client after granting to force it.

Comment
getIdToken(true) was the missing piece, I was confused why the rule still said no tessa_k · edited