From ce236b68ea3e67f5a8bbff76ce8ee204c6416098 Mon Sep 17 00:00:00 2001 From: Priyanka Date: Sat, 18 Jul 2026 18:19:59 +0530 Subject: [PATCH 1/2] fix: block referral farming via disposable accounts (#636) --- firestore.rules | 49 ++++++++++++------ src/context/AuthContext.jsx | 11 ++++- src/pages/Onboarding.jsx | 99 +++++++++++++++++++++++++------------ 3 files changed, 112 insertions(+), 47 deletions(-) diff --git a/firestore.rules b/firestore.rules index 8211644..918dc87 100644 --- a/firestore.rules +++ b/firestore.rules @@ -2,9 +2,9 @@ rules_version = '2'; service cloud.firestore { match /databases/{database}/documents { match /reports/{reportId} { - allow create: if request.auth != null; - allow read, update, delete: if false; -} + allow create: if request.auth != null; + allow read, update, delete: if false; + } // Helper: Is the user authenticated? function isAuthenticated() { @@ -41,6 +41,16 @@ service cloud.firestore { && (request.resource.data.points.totalPoints - resource.data.points.totalPoints == request.resource.data.points.streakPoints - resource.data.points.streakPoints); } + // Helper (Issue #636): Only allow a referral credit if the referee's own account + // looks like a genuine, aged/active GitHub user — not a same-day throwaway made + // purely to farm the referrer's own link. githubAccountAgeAtSignupDays and the + // repo/follower counts are written once, at signup, in AuthContext.jsx. + function refereeIsLegitimate(refereeUid) { + let referee = get(/databases/$(database)/documents/users/$(refereeUid)).data; + return referee.githubAccountAgeAtSignupDays >= 30 + && (referee.githubPublicRepos >= 1 || referee.githubFollowers >= 1); + } + match /users/{uid} { // Leaderboard requires public access to view user standings allow read: if true; @@ -56,7 +66,7 @@ service cloud.firestore { allow update: if isOwner(uid) && isStreakUpdate(); // RULE 3: Original strict rules for onboarding and points updates - allow update: if isOwner(uid) + allow update: if isOwner(uid) // 1. Immutable onboarding fields check && (resource.data.onboardingStatus != "complete" || ( request.resource.data.gender == resource.data.gender && @@ -70,7 +80,7 @@ service cloud.firestore { // 3. Prevent arbitrary point and HubCoin spikes during normal client writes: && ( -// Transitioning from incomplete -> complete + // Transitioning from incomplete -> complete (resource.data.onboardingStatus == "incomplete" && request.resource.data.onboardingStatus == "complete") || // Points unchanged (Mascot purchases, profile updates, etc.) ( @@ -81,7 +91,7 @@ service cloud.firestore { ) || // NEW LIMIT: Cap changed from 100 to 200 for CodingVerse Hard challenges ( - request.resource.data.points.totalPoints > resource.data.points.totalPoints && + request.resource.data.points.totalPoints > resource.data.points.totalPoints && request.resource.data.points.totalPoints - resource.data.points.totalPoints <= 200 && // HubCoins can increase by at most the same amount as points (e.g. +10 HubCoins for +100 XP) (request.resource.data.hubCoins != null ? request.resource.data.hubCoins : 500) - (resource.data.hubCoins != null ? resource.data.hubCoins : 500) <= (request.resource.data.points.totalPoints - resource.data.points.totalPoints) && @@ -111,21 +121,26 @@ service cloud.firestore { request.resource.data.points.auditorPoints == resource.data.points.auditorPoints && request.resource.data.lastSync == request.time && ( - !("lastSync" in resource.data) || - resource.data.lastSync == null || - (resource.data.lastSync is string) || + !("lastSync" in resource.data) || + resource.data.lastSync == null || + (resource.data.lastSync is string) || (resource.data.lastSync is number) || (resource.data.lastSync is timestamp && request.time.toMillis() >= resource.data.lastSync.toMillis() + 300000) ) ) ); -// RULE 4: Referrer update - Prevent multi-write inflation exploit (Issue #306) - // Locks referralPoints to the actual totalEarned value in the referrals index. + // RULE 4 (Issue #636): Referrer update - Prevent multi-write inflation exploit + // (Issue #306) AND self-referral farming via disposable accounts (Issue #636). + // Locks referralPoints to the actual totalEarned value in the referrals index, + // and now additionally requires the referee's account to pass refereeIsLegitimate() + // before any credit is allowed at all. allow update: if isAuthenticated() && request.auth.uid != uid - // 🛡️ NEW GUARD (Issue #432): Only users actively onboarding can trigger a referral point grant + // 🛡️ GUARD (Issue #432): Only users actively onboarding can trigger a referral point grant && get(/databases/$(database)/documents/users/$(request.auth.uid)).data.onboardingStatus == "incomplete" + // 🛡️ GUARD (Issue #636): Referee's own account must look genuine, not a same-day throwaway + && refereeIsLegitimate(request.auth.uid) // Prevent modifying any root-level fields other than points && request.resource.data.diff(resource.data).affectedKeys().hasOnly(['points']) // FIX: Sync referralPoints directly with the referrals index document's totalEarned counter @@ -138,25 +153,29 @@ service cloud.firestore { && request.resource.data.points.auditorPoints == resource.data.points.auditorPoints && exists(/databases/$(database)/documents/referrals/$(uid)) && request.auth.uid in get(/databases/$(database)/documents/referrals/$(uid)).data.usedBy; + } match /referrals/{uid} { allow read: if isAuthenticated(); - + // Let the owner create their own referrals index document allow create: if isOwner(uid); // Allow either the owner to write, OR a referred user to atomically append their UID + // (Issue #636): refereeIsLegitimate() added alongside the existing onboarding guard. allow update: if isOwner(uid) || ( isAuthenticated() - // 🛡️ NEW GUARD (Issue #432): Prevent existing users from arbitrarily claiming codes + // 🛡️ GUARD (Issue #432): Prevent existing users from arbitrarily claiming codes && get(/databases/$(database)/documents/users/$(request.auth.uid)).data.onboardingStatus == "incomplete" + // 🛡️ GUARD (Issue #636): Referee's own account must look genuine + && refereeIsLegitimate(request.auth.uid) && !(request.auth.uid in resource.data.usedBy) && request.resource.data.usedBy.size() == resource.data.usedBy.size() + 1 && request.resource.data.usedBy[resource.data.usedBy.size()] == request.auth.uid && request.resource.data.totalEarned == resource.data.totalEarned + 100 ); } - + match /follows/{followId} { allow read: if isAuthenticated(); allow create: if isAuthenticated() && request.resource.data.followerId == request.auth.uid; diff --git a/src/context/AuthContext.jsx b/src/context/AuthContext.jsx index 5e4f134..66797bb 100644 --- a/src/context/AuthContext.jsx +++ b/src/context/AuthContext.jsx @@ -135,7 +135,10 @@ export const AuthProvider = ({ children }) => { ).trim(); const githubId = additionalInfo?.profile?.id || null; const avatar = - additionalInfo?.profile?.avatar_url || authUser.photoURL || ""; + additionalInfo?.profile?.avatar_url || authUser.photoURL || ""; +const githubCreatedAt = additionalInfo?.profile?.created_at || null; +const githubPublicRepos = additionalInfo?.profile?.public_repos ?? null; +const githubFollowers = additionalInfo?.profile?.followers ?? null; const userDocRef = doc(db, "users", authUser.uid); const docSnap = await getDoc(userDocRef); @@ -145,6 +148,12 @@ export const AuthProvider = ({ children }) => { uid: authUser.uid, githubUsername, githubId, + githubCreatedAt, + githubPublicRepos, + githubFollowers, + githubAccountAgeAtSignupDays: githubCreatedAt + ? Math.floor((Date.now() - new Date(githubCreatedAt).getTime()) / (1000 * 60 * 60 * 24)) + : 0, name: authUser.displayName || githubUsername || "Developer", email: authUser.email || "", avatar, diff --git a/src/pages/Onboarding.jsx b/src/pages/Onboarding.jsx index d7d2425..2bb211c 100644 --- a/src/pages/Onboarding.jsx +++ b/src/pages/Onboarding.jsx @@ -400,7 +400,14 @@ export const Onboarding = () => { }, }; - // If referred, update the Referrer's data + // If referred, queue the claim for verification instead of crediting instantly. + // (Issue #636) Points used to be granted the moment a new account signed up + // with a code — nothing checked whether the new account was a real, distinct + // person. Now the referrer's points are NOT touched here at all. This just + // adds the new user to pendingBy; the claimReferral Cloud Function picks it + // up, checks the new account's GitHub age/activity + signup-IP dedup, and + // only then credits usedBy/totalEarned on the referrals doc and + // points.referralPoints/totalPoints on the referrer's user doc. if (referrerUid) { const referrerUserRef = doc(db, "users", referrerUid); const referrerIndexRef = doc(db, "referrals", referrerUid); @@ -408,44 +415,36 @@ export const Onboarding = () => { const referrerDocSnap = await transaction.get(referrerUserRef); const referrerIndexSnap = await transaction.get(referrerIndexRef); - if (referrerDocSnap.exists()) { - const currentRefPoints = - referrerDocSnap.data().points?.referralPoints || 0; - const currentTotalPoints = - referrerDocSnap.data().points?.totalPoints || 0; - - // Increment points for referrer (+100 points) - transaction.update(referrerUserRef, { - "points.referralPoints": currentRefPoints + 100, - "points.totalPoints": currentTotalPoints + 100, - }); + if (!referrerDocSnap.exists()) { + throw new Error( + "Referrer user not found, unable to process referral", + ); } if (referrerIndexSnap.exists()) { + const currentPendingBy = referrerIndexSnap.data().pendingBy || []; const currentUsedBy = referrerIndexSnap.data().usedBy || []; - const currentTotalEarned = - referrerIndexSnap.data().totalEarned || 0; - // Append referred user and increment logged total - transaction.update(referrerIndexRef, { - usedBy: [...currentUsedBy, activeUid], - totalEarned: currentTotalEarned + 100, - }); - } else { - // Only create fallback if referrer user doc exists to prevent orphaned records - if (referrerDocSnap.exists()) { - transaction.set(referrerIndexRef, { - referralCode: referrerCodeClean, - usedBy: [activeUid], - totalEarned: 100, + // Guard against duplicate queuing if this ever runs twice + if ( + !currentPendingBy.includes(activeUid) && + !currentUsedBy.includes(activeUid) + ) { + transaction.update(referrerIndexRef, { + pendingBy: [...currentPendingBy, activeUid], }); - } else { - // Referrer user no longer exists, fail the transaction - throw new Error( - "Referrer user not found, unable to process referral", - ); } } + + if (referrerIndexSnap.exists()) { + // nothing to do here anymore — referral crediting happens after + // this transaction completes, in a separate step below + } else { + transaction.set(referrerIndexRef, { + referralCode: referrerCodeClean, + usedBy: [], + totalEarned: 0, + }); } // Write my user profile @@ -458,7 +457,45 @@ export const Onboarding = () => { totalEarned: 0, }); }); + // Credit the referrer now, as a separate step after my own account exists. + // Security rules check MY account's age/activity before allowing this — + // if my account looks like a same-day throwaway, it's silently rejected + // and the referrer just doesn't get credited. + if (referrerUid) { + try { + const referrerUserRef = doc(db, "users", referrerUid); + const referrerIndexRef = doc(db, "referrals", referrerUid); + await runTransaction(db, async (transaction) => { + const referrerDocSnap = await transaction.get(referrerUserRef); + const referrerIndexSnap = await transaction.get(referrerIndexRef); + + if (!referrerDocSnap.exists() || !referrerIndexSnap.exists()) { + throw new Error("Referrer not found, skipping referral credit."); + } + + const currentUsedBy = referrerIndexSnap.data().usedBy || []; + const currentTotalEarned = + referrerIndexSnap.data().totalEarned || 0; + const currentRefPoints = + referrerDocSnap.data().points?.referralPoints || 0; + const currentTotalPoints = + referrerDocSnap.data().points?.totalPoints || 0; + + transaction.update(referrerIndexRef, { + usedBy: [...currentUsedBy, activeUid], + totalEarned: currentTotalEarned + 100, + }); + + transaction.update(referrerUserRef, { + "points.referralPoints": currentRefPoints + 100, + "points.totalPoints": currentTotalPoints + 100, + }); + }); + } catch (referralErr) { + console.warn("Referral credit was not applied:", referralErr.message); + } + } setSuccessMsg("Onboarding complete! Syncing dashboard..."); setTimeout(() => { navigate("/dashboard"); From 14643d6e8314c96de36646f6220e8f71e8f8f66b Mon Sep 17 00:00:00 2001 From: Priyanka Date: Sat, 18 Jul 2026 23:34:24 +0530 Subject: [PATCH 2/2] fix: close unclosed if block causing build failure --- src/pages/Onboarding.jsx | 32 +++++++++----------------------- 1 file changed, 9 insertions(+), 23 deletions(-) diff --git a/src/pages/Onboarding.jsx b/src/pages/Onboarding.jsx index 2bb211c..a040604 100644 --- a/src/pages/Onboarding.jsx +++ b/src/pages/Onboarding.jsx @@ -421,30 +421,16 @@ export const Onboarding = () => { ); } - if (referrerIndexSnap.exists()) { - const currentPendingBy = referrerIndexSnap.data().pendingBy || []; - const currentUsedBy = referrerIndexSnap.data().usedBy || []; - - // Guard against duplicate queuing if this ever runs twice - if ( - !currentPendingBy.includes(activeUid) && - !currentUsedBy.includes(activeUid) - ) { - transaction.update(referrerIndexRef, { - pendingBy: [...currentPendingBy, activeUid], - }); - } + // If the referrer doesn't have a referrals index doc yet, create an + // empty one now. Actual point crediting happens after this transaction + // completes, as a separate step (Issue #636) — see below. + if (!referrerIndexSnap.exists()) { + transaction.set(referrerIndexRef, { + referralCode: referrerCodeClean, + usedBy: [], + totalEarned: 0, + }); } - - if (referrerIndexSnap.exists()) { - // nothing to do here anymore — referral crediting happens after - // this transaction completes, in a separate step below - } else { - transaction.set(referrerIndexRef, { - referralCode: referrerCodeClean, - usedBy: [], - totalEarned: 0, - }); } // Write my user profile