From 55065f0126a34a030156745b3dfcb2026983613b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 12 Sep 2026 11:14:19 +0000 Subject: [PATCH] fix(billing): do not grant unlimited access from spoofed Firestore email PAYG users could write an allowlisted email onto users/{uid} because email was client-writable. consumeBillingAction and generate-mystical then treated them as no-charge. Trust Firebase Auth email only and protect email in rules. Co-authored-by: Andy Oliver Rozario --- app/api/profile/edit-quota/route.ts | 5 +-- app/api/profile/generate-mystical/route.ts | 12 +++++--- app/api/profile/record-edit/route.ts | 5 +-- firestore.rules | 1 + lib/billingAccess.ts | 20 ++++++++++++ lib/billingCreditsServer.ts | 19 +++++++++--- tests/unit/billingCredits.test.ts | 36 +++++++++++++++++++++- 7 files changed, 85 insertions(+), 13 deletions(-) diff --git a/app/api/profile/edit-quota/route.ts b/app/api/profile/edit-quota/route.ts index a94fc552..8db89c80 100644 --- a/app/api/profile/edit-quota/route.ts +++ b/app/api/profile/edit-quota/route.ts @@ -26,9 +26,11 @@ export async function GET(request: NextRequest) { } let uid: string; + let authEmail: string | undefined; try { const decoded = await getAuth().verifyIdToken(idToken); uid = decoded.uid; + authEmail = decoded.email; } catch { return NextResponse.json({ error: 'Invalid or expired token' }, { status: 401 }); } @@ -54,8 +56,7 @@ export async function GET(request: NextRequest) { canGenerate: true, }); } - const email = (user.email ?? user.Email) as string | undefined; - if (isNoChargeSubscriptionEmail(email)) { + if (isNoChargeSubscriptionEmail(authEmail)) { return NextResponse.json({ count: 0, limit: 8, diff --git a/app/api/profile/generate-mystical/route.ts b/app/api/profile/generate-mystical/route.ts index ea6177db..75a273db 100644 --- a/app/api/profile/generate-mystical/route.ts +++ b/app/api/profile/generate-mystical/route.ts @@ -22,7 +22,7 @@ import { isNoChargeSubscriptionEmail, } from '@/lib/subscriptionConfig'; import { consumeBillingAction } from '@/lib/billingCreditsServer'; -import { hasUnlimitedBillingAccess } from '@/lib/billingAccess'; +import { hasUnlimitedBillingAccess, withTrustedBillingEmail } from '@/lib/billingAccess'; import { logServerError } from '@/lib/serverErrorLogging'; import { rateLimiters } from '@/lib/rateLimit'; import { checkRateLimitWithOptionalFirestore } from '@/lib/rateLimitFirestore'; @@ -126,6 +126,7 @@ async function writeRegenDecisionTelemetry( export async function POST(request: NextRequest) { let uid: string | undefined; + let authEmail: string | undefined; try { const baseUrlSource = resolveBaseUrlSource(); if (!ensureAdminAvailable('POST /api/profile/generate-mystical')) { @@ -151,6 +152,7 @@ export async function POST(request: NextRequest) { try { const decoded = await getAuth().verifyIdToken(idToken); uid = decoded.uid; + authEmail = decoded.email; } catch { return NextResponse.json({ error: 'Invalid or expired token' }, { status: 401 }); } @@ -192,10 +194,9 @@ export async function POST(request: NextRequest) { ); } - const email = (userProfile.email ?? userProfile.Email) as string | undefined; // Onboarding is intentionally low-friction: generation should not be blocked by payment/plan choice. // Keep this read so existing no-charge account logic remains compatible for downstream analytics/meta. - void isNoChargeSubscriptionEmail(email); + void isNoChargeSubscriptionEmail(authEmail); // Launch hotfix: do not block mystical profile generation by edit quota. // We keep counting edits elsewhere so telemetry remains intact. @@ -251,7 +252,10 @@ export async function POST(request: NextRequest) { ); } if (!isFirstOnboardingGeneration) { - const profileForBilling = profileWithUid as Partial; + const profileForBilling = withTrustedBillingEmail( + profileWithUid as Partial, + authEmail, + ); if (!hasUnlimitedBillingAccess(profileForBilling)) { const billing = await consumeBillingAction(uid, 'profile_regen'); if (!billing.ok) { diff --git a/app/api/profile/record-edit/route.ts b/app/api/profile/record-edit/route.ts index 90f3b2de..9409ffa2 100644 --- a/app/api/profile/record-edit/route.ts +++ b/app/api/profile/record-edit/route.ts @@ -29,9 +29,11 @@ export async function POST(request: NextRequest) { } let uid: string; + let authEmail: string | undefined; try { const decoded = await getAuth().verifyIdToken(idToken); uid = decoded.uid; + authEmail = decoded.email; } catch { return NextResponse.json({ error: 'Invalid or expired token' }, { status: 401 }); } @@ -57,8 +59,7 @@ export async function POST(request: NextRequest) { canGenerate: true, }); } - const email = (user.email ?? user.Email) as string | undefined; - if (isNoChargeSubscriptionEmail(email)) { + if (isNoChargeSubscriptionEmail(authEmail)) { return NextResponse.json({ count: 0, limit: 8, diff --git a/firestore.rules b/firestore.rules index d80d1e32..bb285e05 100644 --- a/firestore.rules +++ b/firestore.rules @@ -79,6 +79,7 @@ service cloud.firestore { 'subscriptionId', 'subscriptionStatus', 'noChargeAccount', + 'email', 'trialEndDate', 'trialEndTime', 'nextBillingDate', diff --git a/lib/billingAccess.ts b/lib/billingAccess.ts index 28394c45..2d695363 100644 --- a/lib/billingAccess.ts +++ b/lib/billingAccess.ts @@ -2,6 +2,26 @@ import { hasActiveSubscriptionAccess } from '@/lib/authRouting'; import { isNoChargeSubscriptionEmail } from '@/lib/subscriptionConfig'; import type { BillingUserFields } from '@/lib/billingTypes'; +/** + * Email used for no-charge allowlist checks. Only Firebase Auth email is trusted. + * Firestore `users.email` is client-writable and must not grant unlimited billing. + */ +export function trustedBillingEmail( + authEmail: string | null | undefined, +): string | undefined { + if (typeof authEmail !== 'string') return undefined; + const trimmed = authEmail.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + +/** Replace any client-supplied email with the Auth token / Admin Auth email. */ +export function withTrustedBillingEmail( + profile: T, + authEmail: string | null | undefined, +): T { + return { ...profile, email: trustedBillingEmail(authEmail) }; +} + /** Unlimited AI / regen — active membership or comp accounts. */ export function hasUnlimitedBillingAccess(profile: BillingUserFields | null | undefined): boolean { if (!profile) return false; diff --git a/lib/billingCreditsServer.ts b/lib/billingCreditsServer.ts index ee56ec33..015ee2ae 100644 --- a/lib/billingCreditsServer.ts +++ b/lib/billingCreditsServer.ts @@ -2,9 +2,9 @@ import 'server-only'; import { FieldValue } from 'firebase-admin/firestore'; import { devLog } from '@/lib/devLogger'; -import { adminDb } from '@/lib/firebase-admin'; +import { adminDb, getAuth } from '@/lib/firebase-admin'; import { CREDIT_COSTS, CREDIT_PACK_DEFS } from '@/lib/billingConfig'; -import { hasUnlimitedBillingAccess } from '@/lib/billingAccess'; +import { hasUnlimitedBillingAccess, withTrustedBillingEmail } from '@/lib/billingAccess'; import { creditBalanceFromProfile, isFreeInstanceAvailable, @@ -18,6 +18,15 @@ import type { CreditPackId, } from '@/lib/billingTypes'; +async function authEmailForUser(userId: string): Promise { + try { + const user = await getAuth().getUser(userId); + return typeof user.email === 'string' ? user.email : undefined; + } catch { + return undefined; + } +} + function userBillingFromData(data: FirebaseFirestore.DocumentData | undefined): BillingUserFields { if (!data) return {}; return { @@ -46,7 +55,8 @@ export async function getBillingSnapshot(userId: string): Promise<{ return { creditBalance: 0, billingMode: 'payg', unlimited: false, freeUseConsumed: {} }; } const snap = await adminDb.collection('users').doc(userId).get(); - const profile = userBillingFromData(snap.data()); + const authEmail = await authEmailForUser(userId); + const profile = withTrustedBillingEmail(userBillingFromData(snap.data()), authEmail); const unlimited = hasUnlimitedBillingAccess(profile); return { creditBalance: creditBalanceFromProfile(profile), @@ -79,6 +89,7 @@ export async function consumeBillingAction( const toolSlug = opts?.toolSlug?.trim() || undefined; const userRef = adminDb.collection('users').doc(userId); const ledgerRef = userRef.collection('billingLedger').doc(); + const authEmail = await authEmailForUser(userId); try { return await adminDb.runTransaction(async (tx) => { @@ -92,7 +103,7 @@ export async function consumeBillingAction( }; } - const profile = userBillingFromData(snap.data()); + const profile = withTrustedBillingEmail(userBillingFromData(snap.data()), authEmail); if (hasUnlimitedBillingAccess(profile)) { return { ok: true as const, diff --git a/tests/unit/billingCredits.test.ts b/tests/unit/billingCredits.test.ts index e07ff764..b90af596 100644 --- a/tests/unit/billingCredits.test.ts +++ b/tests/unit/billingCredits.test.ts @@ -4,7 +4,11 @@ import { creditBalanceFromProfile, } from '@/lib/billingFreeUse'; import { getCreditPackPrice, toolSlugFromSeerRoute } from '@/lib/billingConfig'; -import { hasUnlimitedBillingAccess } from '@/lib/billingAccess'; +import { + hasUnlimitedBillingAccess, + trustedBillingEmail, + withTrustedBillingEmail, +} from '@/lib/billingAccess'; describe('billingFreeUse', () => { it('tracks per-tool first free Seer use', () => { @@ -46,4 +50,34 @@ describe('billingAccess', () => { ).toBe(true); expect(creditBalanceFromProfile({ creditBalance: 5 })).toBe(5); }); + + it('does not trust a client-written Firestore email for billing identity', () => { + const previous = process.env.NO_CHARGE_SUBSCRIPTION_EMAILS; + process.env.NO_CHARGE_SUBSCRIPTION_EMAILS = 'founder@example.com'; + try { + expect(trustedBillingEmail(undefined)).toBeUndefined(); + expect(trustedBillingEmail('')).toBeUndefined(); + expect(trustedBillingEmail(' ')).toBeUndefined(); + expect(trustedBillingEmail('user@example.com')).toBe('user@example.com'); + + expect(hasUnlimitedBillingAccess({ email: 'founder@example.com' })).toBe(true); + + const fromFirestore = withTrustedBillingEmail( + { email: 'founder@example.com', creditBalance: 3 }, + undefined, + ); + expect(fromFirestore.email).toBeUndefined(); + expect(hasUnlimitedBillingAccess(fromFirestore)).toBe(false); + + const fromAuth = withTrustedBillingEmail( + { email: 'founder@example.com', creditBalance: 3 }, + 'user@example.com', + ); + expect(fromAuth.email).toBe('user@example.com'); + expect(hasUnlimitedBillingAccess(fromAuth)).toBe(false); + } finally { + if (previous === undefined) delete process.env.NO_CHARGE_SUBSCRIPTION_EMAILS; + else process.env.NO_CHARGE_SUBSCRIPTION_EMAILS = previous; + } + }); });