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..a040604 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,43 +415,21 @@ 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 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, + // 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, }); - } 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, - }); - } else { - // Referrer user no longer exists, fail the transaction - throw new Error( - "Referrer user not found, unable to process referral", - ); - } } } @@ -458,7 +443,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");