security: fix invite rules missing-doc bypass, webhook timing attack, entitlement replay protection and entitlement_id check
This commit is contained in:
parent
f45f8dd114
commit
bd1ea5cecd
|
|
@ -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 ───────────────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
|
|
|
|||
Loading…
Reference in New Issue