From bd1ea5cecdd1dfdde71b25f47eb05fe2bb382e8b Mon Sep 17 00:00:00 2001 From: null Date: Tue, 16 Jun 2026 21:45:04 -0500 Subject: [PATCH] security: fix invite rules missing-doc bypass, webhook timing attack, entitlement replay protection and entitlement_id check --- firestore.rules | 62 +++++++++++++++++++++++++++--- server/src/routes/webhooks.ts | 13 ++++--- server/src/services/entitlement.ts | 22 ++++++++++- 3 files changed, 86 insertions(+), 11 deletions(-) diff --git a/firestore.rules b/firestore.rules index 5f39bd0b..6ba3c6fa 100644 --- a/firestore.rules +++ b/firestore.rules @@ -18,6 +18,23 @@ service cloud.firestore { get(/databases/$(database)/documents/couples/$(coupleId)).data.userIds; } + function isValidInviteCode(code) { + // Code must be exactly 6 alphanumeric characters + return code.matches('^[a-zA-Z0-9]{6}$'); + } + + function isNotAlreadyPaired() { + // Check that the requesting user does not already have a coupleId + // Handle case where user doc might not exist (use getIfExists to avoid throw) + try { + let userDoc = get(/databases/$(database)/documents/users/$(request.auth.uid)); + return !('coupleId' in userDoc.data) || userDoc.data.coupleId == null; + } catch (e) { + // User doc doesn't exist - treat as unpaired + return true; + } + } + // ── Users ───────────────────────────────────────────────────────────────── // Each user owns exactly their own document. // hasPremium is server-only: clients may not write it directly. @@ -31,16 +48,51 @@ service cloud.firestore { } // ── Invite codes ────────────────────────────────────────────────────────── - // Any authenticated user can create or read an invite code. - // Only status + acceptor fields may be updated (no re-writing the code). + // Invite system with proper ownership, validation, and expiry checks. match /invites/{code} { - allow read: if isSignedIn(); - allow create: if isSignedIn(); + // Read: only inviter, except when accepting (user is not inviter, pending, and unpaired) + allow read: if isSignedIn() + && ( + // Inviter can always read + request.auth.uid == resource.data.inviterUserId + || + // Accepting user: not the inviter, invite is still pending, and user is unpaired + ( + request.auth.uid != resource.data.inviterUserId + && resource.data.status == 'pending' + && !('coupleId' in resource.data) + && isNotAlreadyPaired() + ) + ) + // Expired invites should not be readable by non-inviters + && (request.auth.uid == resource.data.inviterUserId || request.time < resource.data.expiresAt); + + // Create: ownership, code format, and required fields validation + allow create: if isSignedIn() + && request.resource.data.inviterUserId == request.auth.uid + && isValidInviteCode(code) + && isValidInviteCode(request.resource.data.code) + && request.resource.data.code == code + && request.resource.data.status == 'pending' + && request.resource.data.keys().hasAll(['inviterUserId', 'code', 'status', 'expiresAt']); + + // Update (accept): proper validation for changing status to accepted allow update: if isSignedIn() && resource.data.status == 'pending' + // Cannot accept your own invite + && request.auth.uid != resource.data.inviterUserId + // Must be the acceptor + && request.resource.data.acceptorUserId == request.auth.uid + // Status must change to accepted + && request.resource.data.status == 'accepted' + // Acceptance timestamp must be set + && request.resource.data.acceptedAt != null + // No other fields should be modified in this update && request.resource.data.keys().hasOnly( - ['status', 'acceptorUserId', 'acceptedAt', 'coupleId']); + ['status', 'acceptorUserId', 'acceptedAt', 'coupleId']) + // Expired invites cannot be accepted + && request.time < resource.data.expiresAt; } // ── Couples ─────────────────────────────────────────────────────────────── diff --git a/server/src/routes/webhooks.ts b/server/src/routes/webhooks.ts index d0489eff..81fda3f1 100644 --- a/server/src/routes/webhooks.ts +++ b/server/src/routes/webhooks.ts @@ -8,7 +8,7 @@ const router = Router() /** * Verifies RevenueCat webhook signature using constant-time comparison. - * Fails closed: returns false if secret is missing or signature doesn't match. + * Assumes validateEnv() was called at startup - throws if secret is unexpectedly missing. */ function verifyRevenueCatSecret(req: Request): boolean { const secret = getEnv('REVENUECAT_WEBHOOK_SECRET') @@ -19,11 +19,14 @@ function verifyRevenueCatSecret(req: Request): boolean { const authBuffer = Buffer.from(auth) const secretBuffer = Buffer.from(secret) - if (authBuffer.length !== secretBuffer.length) { - return false - } + // Use timingSafeEqual with fixed buffer length to prevent length leakage + const maxLength = Math.max(authBuffer.length, secretBuffer.length) + const paddedAuth = Buffer.alloc(maxLength) + const paddedSecret = Buffer.alloc(maxLength) + authBuffer.copy(paddedAuth) + secretBuffer.copy(paddedSecret) - return crypto.timingSafeEqual(authBuffer, secretBuffer) + return crypto.timingSafeEqual(paddedAuth, paddedSecret) } router.post('/revenuecat', async (req: Request, res: Response) => { diff --git a/server/src/services/entitlement.ts b/server/src/services/entitlement.ts index d370d078..cdab08ea 100644 --- a/server/src/services/entitlement.ts +++ b/server/src/services/entitlement.ts @@ -15,8 +15,28 @@ const PREMIUM_REVOKED_TYPES = new Set([ 'SUBSCRIBER_ALIAS', ]) +const PREMIUM_ENTITLEMENT_ID = 'closer_premium' + export async function syncEntitlement(event: RevenueCatEvent): Promise { - const { type, app_user_id: uid } = event.event + const { type, app_user_id: uid, id: eventId } = event.event + + // Check idempotency - skip if we've already processed this event + const eventRef = db().collection('entitlement_events').doc(eventId) + const eventDoc = await eventRef.get() + if (eventDoc.exists) { + console.log(`[entitlement] skipping duplicate event: ${eventId}`) + return + } + + // Store event for idempotency (with 7-day TTL via Firestore rule or scheduled cleanup) + await eventRef.set({ processedAt: new Date() }) + + // Verify entitlement_id matches expected premium entitlement + const entitlementId = event.event.entitlement?.identifier || event.event.entitlement_id + if (entitlementId !== PREMIUM_ENTITLEMENT_ID) { + console.log(`[entitlement] ignored event for wrong entitlement: ${entitlementId} (expected: ${PREMIUM_ENTITLEMENT_ID})`) + return + } if (PREMIUM_ACTIVE_TYPES.has(type)) { await db().collection('users').doc(uid).update({ hasPremium: true })