Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 34 additions & 15 deletions firestore.rules
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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;
Expand All @@ -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 &&
Expand All @@ -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.)
(
Expand All @@ -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) &&
Expand Down Expand Up @@ -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
Expand All @@ -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;
Expand Down
11 changes: 10 additions & 1 deletion src/context/AuthContext.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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,
Expand Down
93 changes: 58 additions & 35 deletions src/pages/Onboarding.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -400,51 +400,36 @@ 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);

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",
);
}
}
}

Expand All @@ -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");
Expand Down
Loading