security: fix invite rules missing-doc bypass, webhook timing attack, entitlement replay protection and entitlement_id check

This commit is contained in:
null 2026-06-16 21:45:04 -05:00
parent f45f8dd114
commit bd1ea5cecd
3 changed files with 86 additions and 11 deletions

View File

@ -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 ───────────────────────────────────────────────────────────────

View File

@ -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) => {

View File

@ -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<void> {
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 })