From 0c2e1a0cac1fd7325ccbb1bc906b0f903b2c2217 Mon Sep 17 00:00:00 2001 From: Developer Date: Thu, 3 Sep 2026 19:47:25 -0400 Subject: [PATCH] fix: harden rider social privacy and security --- .../emulator-tests/firestore.rules.test.js | 186 ++++++++++++++++++ firestore.rules | 94 ++++++++- .../cloud/account_cloud_cleanup_service.dart | 39 ++++ lib/services/social/rider_profile_models.dart | 39 ++++ .../social/rider_profile_service.dart | 73 ++++--- test/rider_profile_test.dart | 21 ++ 6 files changed, 413 insertions(+), 39 deletions(-) diff --git a/firebase/emulator-tests/firestore.rules.test.js b/firebase/emulator-tests/firestore.rules.test.js index 801f103..dc281b8 100644 --- a/firebase/emulator-tests/firestore.rules.test.js +++ b/firebase/emulator-tests/firestore.rules.test.js @@ -186,3 +186,189 @@ describe('MotorCloud ride ownership rules', () => { } }); }); + +describe('Social Architecture Security Rules', () => { + test('public profile creation, reading, and privacy protection', async () => { + const alice = testEnv.authenticatedContext('alice').firestore(); + const bob = testEnv.authenticatedContext('bob').firestore(); + + // Alice creates public profile + await assertSucceeds(setDoc(doc(alice, 'public_profiles/alice'), { + id: 'alice', + username: 'alice_apex', + displayName: 'Alice Apex', + avatarPreset: 'helmet_cyan', + bio: 'Apex hunter', + location: 'Atlanta', + isSearchableByHandle: true, + isProfilePublic: true, + allowFriendRequests: true, + })); + + // Bob can read Alice's public profile + await assertSucceeds(getDoc(doc(bob, 'public_profiles/alice'))); + + // Bob CANNOT edit Alice's public profile + await assertFails(updateDoc(doc(bob, 'public_profiles/alice'), { bio: 'Hacked' })); + + // Bob CANNOT list all public profiles + await assertFails(getDocs(collection(bob, 'public_profiles'))); + + // Alice sets isSearchableByHandle = false + await assertSucceeds(updateDoc(doc(alice, 'public_profiles/alice'), { + isSearchableByHandle: false, + })); + + // Bob can no longer read Alice's non-searchable profile since they are not friends + await assertFails(getDoc(doc(bob, 'public_profiles/alice'))); + }); + + test('handle reservation uniqueness and directory scraping prevention', async () => { + const alice = testEnv.authenticatedContext('alice').firestore(); + const bob = testEnv.authenticatedContext('bob').firestore(); + + // Alice creates public profile first so handle lookup can check searchability + await testEnv.withSecurityRulesDisabled(async (context) => { + const db = context.firestore(); + await setDoc(doc(db, 'public_profiles/alice'), { + id: 'alice', + username: 'alice_apex', + isSearchableByHandle: true, + }); + }); + + // Alice claims handle + await assertSucceeds(setDoc(doc(alice, 'handles/alice_apex'), { + uid: 'alice', + handle: 'alice_apex', + })); + + // Bob CANNOT overwrite Alice's handle + await assertFails(setDoc(doc(bob, 'handles/alice_apex'), { + uid: 'bob', + handle: 'alice_apex', + })); + + // Bob CANNOT delete Alice's handle + await assertFails(deleteDoc(doc(bob, 'handles/alice_apex'))); + + // Bob CANNOT list all handles + await assertFails(getDocs(collection(bob, 'handles'))); + }); + + test('friend requests: legitimate vs forged vs blocked vs disabled', async () => { + const alice = testEnv.authenticatedContext('alice').firestore(); + const bob = testEnv.authenticatedContext('bob').firestore(); + + // Seed Bob's public profile accepting requests + await testEnv.withSecurityRulesDisabled(async (context) => { + const db = context.firestore(); + await setDoc(doc(db, 'public_profiles/bob'), { + id: 'bob', + username: 'bob_rider', + allowFriendRequests: true, + }); + }); + + // Alice sends legitimate friend request to Bob + await assertSucceeds(setDoc(doc(alice, 'users/bob/friend_requests_incoming/alice'), { + receiverUid: 'bob', + senderUid: 'alice', + senderHandle: 'alice_apex', + })); + + // Alice CANNOT forge request pretending to be Charlie + await assertFails(setDoc(doc(alice, 'users/bob/friend_requests_incoming/charlie'), { + receiverUid: 'bob', + senderUid: 'charlie', + senderHandle: 'charlie', + })); + + // Alice CANNOT send friend request to herself + await assertFails(setDoc(doc(alice, 'users/alice/friend_requests_incoming/alice'), { + receiverUid: 'alice', + senderUid: 'alice', + senderHandle: 'alice', + })); + }); + + test('friendship establishment requires incoming request; unilateral declaration fails', async () => { + const alice = testEnv.authenticatedContext('alice').firestore(); + const bob = testEnv.authenticatedContext('bob').firestore(); + + // Unilateral friendship: Alice tries to add Bob directly as a friend without incoming request -> FAILS + await assertFails(setDoc(doc(alice, 'users/alice/friends/bob'), { + uid: 'bob', + handle: 'bob_rider', + })); + + // Seed incoming request from Alice to Bob + await testEnv.withSecurityRulesDisabled(async (context) => { + const db = context.firestore(); + await setDoc(doc(db, 'users/bob/friend_requests_incoming/alice'), { + receiverUid: 'bob', + senderUid: 'alice', + }); + }); + + // Bob accepts request -> SUCCEEDS because Bob has incoming request from Alice + await assertSucceeds(setDoc(doc(bob, 'users/bob/friends/alice'), { + uid: 'alice', + handle: 'alice_apex', + })); + + // Bob can also create Alice's corresponding friend doc in the mutual acceptance + await assertSucceeds(setDoc(doc(bob, 'users/alice/friends/bob'), { + uid: 'bob', + handle: 'bob_rider', + })); + + // Outsider Charlie CANNOT delete Alice and Bob's friendship + const charlie = testEnv.authenticatedContext('charlie').firestore(); + await assertFails(deleteDoc(doc(charlie, 'users/alice/friends/bob'))); + + // Either participant (Alice) can remove friend + await assertSucceeds(deleteDoc(doc(alice, 'users/alice/friends/bob'))); + }); + + test('blocking prevents requests, hides profiles, and blocks cannot be forged', async () => { + const alice = testEnv.authenticatedContext('alice').firestore(); + const bob = testEnv.authenticatedContext('bob').firestore(); + + // Seed Alice's public profile + await testEnv.withSecurityRulesDisabled(async (context) => { + const db = context.firestore(); + await setDoc(doc(db, 'public_profiles/alice'), { + id: 'alice', + username: 'alice_apex', + isSearchableByHandle: true, + allowFriendRequests: true, + }); + await setDoc(doc(db, 'handles/alice_apex'), { + uid: 'alice', + handle: 'alice_apex', + }); + }); + + // Alice blocks Bob + await assertSucceeds(setDoc(doc(alice, 'users/alice/blocks/bob'), { + blockedUid: 'bob', + })); + + // Bob CANNOT read or delete Alice's block doc + await assertFails(getDoc(doc(bob, 'users/alice/blocks/bob'))); + await assertFails(deleteDoc(doc(bob, 'users/alice/blocks/bob'))); + + // Bob CANNOT read Alice's public profile once blocked + await assertFails(getDoc(doc(bob, 'public_profiles/alice'))); + + // Bob CANNOT get Alice's handle doc once blocked + await assertFails(getDoc(doc(bob, 'handles/alice_apex'))); + + // Bob CANNOT send friend request to Alice once blocked + await assertFails(setDoc(doc(bob, 'users/alice/friend_requests_incoming/bob'), { + receiverUid: 'alice', + senderUid: 'bob', + })); + }); +}); diff --git a/firestore.rules b/firestore.rules index ac6892a..2f68d09 100644 --- a/firestore.rules +++ b/firestore.rules @@ -28,6 +28,26 @@ service cloud.firestore { ]); } + function validPublicProfileCreate(userId) { + return ownsUser(userId) + && request.resource.data.id == userId + && request.resource.data.keys().hasOnly([ + 'id', 'username', 'displayName', 'profilePhotoUrl', 'avatarPreset', 'bio', + 'location', 'isSearchableByHandle', 'isProfilePublic', 'allowFriendRequests', + 'updatedAt' + ]); + } + + function validPublicProfileUpdate(userId) { + return ownsUser(userId) + && request.resource.data.id == resource.data.id + && request.resource.data.keys().hasOnly([ + 'id', 'username', 'displayName', 'profilePhotoUrl', 'avatarPreset', 'bio', + 'location', 'isSearchableByHandle', 'isProfilePublic', 'allowFriendRequests', + 'updatedAt' + ]); + } + function validRideCreate(userId, rideId) { return ownsUser(userId) && request.resource.data.ownerId == userId @@ -41,8 +61,26 @@ service cloud.firestore { && request.resource.data.id == resource.data.id; } + function isBlockedBy(sourceUid, targetUid) { + return exists(/databases/$(database)/documents/users/$(targetUid)/blocks/$(sourceUid)); + } + + function areMutuallyBlocked(userA, userB) { + return isBlockedBy(userA, userB) || isBlockedBy(userB, userA); + } + + // --- HANDLE RESERVATIONS --- + // Internal uniqueness index. Read access is guarded to prevent + // turning handle lookup into an open user enumeration directory. match /handles/{handle} { - allow read: if signedIn(); + allow list: if false; + allow get: if signedIn() && ( + resource == null || + resource.data.uid == request.auth.uid || ( + !areMutuallyBlocked(request.auth.uid, resource.data.uid) && + get(/databases/$(database)/documents/public_profiles/$(resource.data.uid)).data.isSearchableByHandle == true + ) + ); allow create: if signedIn() && request.resource.data.uid == request.auth.uid && request.resource.data.handle == handle; @@ -50,18 +88,35 @@ service cloud.firestore { allow update: if false; } + // --- PUBLIC PROFILES --- + // Contains ONLY safe public fields. No email, tokens, rides, or billing. + match /public_profiles/{userId} { + allow list: if false; + allow get: if signedIn() && ( + ownsUser(userId) || ( + !areMutuallyBlocked(request.auth.uid, userId) && + ( + resource.data.isSearchableByHandle == true || + exists(/databases/$(database)/documents/users/$(userId)/friends/$(request.auth.uid)) + ) + ) + ); + allow create: if validPublicProfileCreate(userId); + allow update: if validPublicProfileUpdate(userId); + allow delete: if ownsUser(userId); + } + + // --- PRIVATE USER ACCOUNTS --- + // Strictly private to the account owner. match /users/{userId} { allow create: if validUserCreate(userId); - allow get: if signedIn(); - allow list: if ownsUser(userId); + allow read: if ownsUser(userId); allow update: if validUserUpdate(userId); allow delete: if ownsUser(userId); match /rides/{rideId} { allow create: if validRideCreate(userId, rideId); allow update: if validRideUpdate(userId); - // Listing is path-scoped to the authenticated owner. Individual - // reads and deletes retain the persisted ownerId check. allow list: if ownsUser(userId); allow get, delete: if ownsUser(userId) && resource.data.ownerId == userId; @@ -87,21 +142,44 @@ service cloud.firestore { allow read, write: if ownsUser(userId); } + // --- SOCIAL SUBCOLLECTIONS --- match /friends/{friendId} { allow read: if ownsUser(userId); - allow write: if signedIn() && (request.auth.uid == userId || request.auth.uid == friendId); + allow create: if signedIn() + && (request.auth.uid == userId || request.auth.uid == friendId) + && userId != friendId + && !areMutuallyBlocked(userId, friendId) + && ( + (request.auth.uid == userId && exists(/databases/$(database)/documents/users/$(userId)/friend_requests_incoming/$(friendId))) || + (request.auth.uid == friendId && exists(/databases/$(database)/documents/users/$(friendId)/friend_requests_incoming/$(userId))) + ); + allow delete: if signedIn() && (request.auth.uid == userId || request.auth.uid == friendId); + allow update: if false; } match /friend_requests_incoming/{senderId} { allow read: if ownsUser(userId); - allow create: if signedIn() && request.auth.uid == senderId && request.resource.data.receiverUid == userId; + allow create: if signedIn() + && request.auth.uid == senderId + && senderId != userId + && request.resource.data.receiverUid == userId + && request.resource.data.senderUid == senderId + && !areMutuallyBlocked(userId, senderId) + && !exists(/databases/$(database)/documents/users/$(userId)/friends/$(senderId)) + && get(/databases/$(database)/documents/public_profiles/$(userId)).data.allowFriendRequests == true; allow delete: if signedIn() && (request.auth.uid == userId || request.auth.uid == senderId); + allow update: if false; } match /friend_requests_outgoing/{receiverId} { allow read: if ownsUser(userId); - allow create: if ownsUser(userId) && request.resource.data.receiverUid == receiverId; + allow create: if ownsUser(userId) + && receiverId != userId + && request.resource.data.receiverUid == receiverId + && request.resource.data.senderUid == userId + && !areMutuallyBlocked(userId, receiverId); allow delete: if signedIn() && (request.auth.uid == userId || request.auth.uid == receiverId); + allow update: if false; } match /blocks/{blockedId} { diff --git a/lib/services/cloud/account_cloud_cleanup_service.dart b/lib/services/cloud/account_cloud_cleanup_service.dart index d226fa7..82220d1 100644 --- a/lib/services/cloud/account_cloud_cleanup_service.dart +++ b/lib/services/cloud/account_cloud_cleanup_service.dart @@ -77,6 +77,42 @@ class AccountCloudCleanupService implements CloudAccountCleanup { await _deleteCollection(userRef.collection('rides')); await _deleteCollection(userRef.collection('motorcycles')); await _deleteCollection(userRef.collection('trips')); + // Clean up reciprocal relationships on other users before deleting own references + final friendsSnap = await userRef.collection('friends').get(); + for (final friendDoc in friendsSnap.docs) { + final friendUid = friendDoc.id; + final reciprocalFriendRef = firestore + .collection('users') + .doc(friendUid) + .collection('friends') + .doc(user.uid); + await reciprocalFriendRef.delete().catchError((_) {}); + } + + final incomingRequestsSnap = + await userRef.collection('friend_requests_incoming').get(); + for (final incDoc in incomingRequestsSnap.docs) { + final senderUid = incDoc.id; + final reciprocalOutgoingRef = firestore + .collection('users') + .doc(senderUid) + .collection('friend_requests_outgoing') + .doc(user.uid); + await reciprocalOutgoingRef.delete().catchError((_) {}); + } + + final outgoingRequestsSnap = + await userRef.collection('friend_requests_outgoing').get(); + for (final outDoc in outgoingRequestsSnap.docs) { + final recipientUid = outDoc.id; + final reciprocalIncomingRef = firestore + .collection('users') + .doc(recipientUid) + .collection('friend_requests_incoming') + .doc(user.uid); + await reciprocalIncomingRef.delete().catchError((_) {}); + } + await _deleteCollection(userRef.collection('events')); await _deleteCollection(userRef.collection('friends')); await _deleteCollection(userRef.collection('friend_requests_incoming')); @@ -90,6 +126,9 @@ class AccountCloudCleanupService implements CloudAccountCleanup { await firestore.collection('handles').doc(handle.toLowerCase()).delete(); } + // Delete public profile projection + await firestore.collection('public_profiles').doc(user.uid).delete().catchError((_) {}); + // Delete the profile last so retries can still discover the account scope. final profileBatch = firestore.batch()..delete(userRef); await profileBatch.commit(); diff --git a/lib/services/social/rider_profile_models.dart b/lib/services/social/rider_profile_models.dart index e1c285d..889f1bf 100644 --- a/lib/services/social/rider_profile_models.dart +++ b/lib/services/social/rider_profile_models.dart @@ -174,6 +174,45 @@ class RiderProfile { 'updatedAt': updatedAt.toIso8601String(), }; + /// Serializes into the minimal, safe public profile projection for `public_profiles/{uid}`. + /// Strictly isolates public fields from private user data. + Map toPublicProfileJson() => { + 'id': uid, + 'username': handle, + 'displayName': displayName, + if (avatarUrl != null) 'profilePhotoUrl': avatarUrl, + if (avatarPreset != null) 'avatarPreset': avatarPreset, + if (bio.isNotEmpty) 'bio': bio, + if (region.isNotEmpty) 'location': region, + 'isSearchableByHandle': privacySettings.isSearchableByHandle, + 'isProfilePublic': privacySettings.isProfilePublic, + 'allowFriendRequests': privacySettings.allowFriendRequests, + 'updatedAt': updatedAt.toIso8601String(), + }; + + factory RiderProfile.fromPublicProfileJson(Map json) { + final updated = json['updatedAt'] != null + ? DateTime.tryParse(json['updatedAt'] as String) ?? DateTime.now() + : DateTime.now(); + + return RiderProfile( + uid: json['id'] as String? ?? '', + handle: (json['username'] as String? ?? '').toLowerCase().trim(), + displayName: json['displayName'] as String? ?? '', + bio: json['bio'] as String? ?? '', + region: json['location'] as String? ?? '', + avatarPreset: json['avatarPreset'] as String?, + avatarUrl: json['profilePhotoUrl'] as String?, + privacySettings: ProfilePrivacySettings( + isSearchableByHandle: json['isSearchableByHandle'] as bool? ?? true, + isProfilePublic: json['isProfilePublic'] as bool? ?? true, + allowFriendRequests: json['allowFriendRequests'] as bool? ?? true, + ), + createdAt: updated, + updatedAt: updated, + ); + } + factory RiderProfile.fromJson(Map json) { final created = json['createdAt'] != null ? DateTime.tryParse(json['createdAt'] as String) ?? DateTime.now() diff --git a/lib/services/social/rider_profile_service.dart b/lib/services/social/rider_profile_service.dart index 03b0c27..f938939 100644 --- a/lib/services/social/rider_profile_service.dart +++ b/lib/services/social/rider_profile_service.dart @@ -22,17 +22,34 @@ class RiderProfileService { bool get isAvailable => _firestore != null; - /// Fetches a rider's profile by UID. + /// Fetches a rider's profile by UID (reads owner profile, falls back to public profile). Future getProfile(String uid) async { final firestore = _firestore; if (firestore == null) return null; try { final doc = await firestore.collection('users').doc(uid).get(); - if (!doc.exists || doc.data() == null) return null; - return RiderProfile.fromJson(doc.data()!); + if (doc.exists && doc.data() != null) { + return RiderProfile.fromJson(doc.data()!); + } + return await getPublicProfile(uid); } catch (e) { - debugPrint('[RiderProfileService] Failed to load profile for $uid: $e'); + // If reading private user doc fails (e.g. PERMISSION_DENIED for other user), read public projection + return await getPublicProfile(uid); + } + } + + /// Fetches the public projection of a rider's profile. + Future getPublicProfile(String uid) async { + final firestore = _firestore; + if (firestore == null) return null; + + try { + final doc = await firestore.collection('public_profiles').doc(uid).get(); + if (!doc.exists || doc.data() == null) return null; + return RiderProfile.fromPublicProfileJson(doc.data()!); + } catch (e) { + debugPrint('[RiderProfileService] Failed to load public profile for $uid: $e'); return null; } } @@ -121,6 +138,7 @@ class RiderProfileService { } final userRef = firestore.collection('users').doc(uid); + final publicRef = firestore.collection('public_profiles').doc(uid); final handleRef = firestore.collection('handles').doc(normalizedHandle); // Read current user profile to determine previous handle if changing @@ -134,6 +152,19 @@ class RiderProfileService { final isChangingHandle = previousHandle != null && previousHandle != normalizedHandle; + final updatedProfile = RiderProfile( + uid: uid, + handle: normalizedHandle, + displayName: displayName.trim().isEmpty ? normalizedHandle : displayName.trim(), + bio: bio.trim(), + region: region.trim(), + avatarPreset: avatarPreset, + avatarUrl: avatarUrl, + privacySettings: privacySettings ?? existingProfile?.privacySettings ?? const ProfilePrivacySettings(), + createdAt: existingProfile?.createdAt ?? now, + updatedAt: now, + ); + await firestore.runTransaction((transaction) async { // 1. Verify handle availability final handleSnapshot = await transaction.get(handleRef); @@ -157,37 +188,15 @@ class RiderProfileService { transaction.delete(oldHandleRef); } - // 4. Save profile document - final updatedProfile = RiderProfile( - uid: uid, - handle: normalizedHandle, - displayName: displayName.trim().isEmpty ? normalizedHandle : displayName.trim(), - bio: bio.trim(), - region: region.trim(), - avatarPreset: avatarPreset, - avatarUrl: avatarUrl, - privacySettings: privacySettings ?? existingProfile?.privacySettings ?? const ProfilePrivacySettings(), - createdAt: existingProfile?.createdAt ?? now, - updatedAt: now, - ); - + // 4. Save private user profile document transaction.set(userRef, updatedProfile.toJson(), SetOptions(merge: true)); + + // 5. Save public profile projection + transaction.set(publicRef, updatedProfile.toPublicProfileJson(), SetOptions(merge: true)); }); final saved = await getProfile(uid); - return saved ?? - RiderProfile( - uid: uid, - handle: normalizedHandle, - displayName: displayName.trim(), - bio: bio.trim(), - region: region.trim(), - avatarPreset: avatarPreset, - avatarUrl: avatarUrl, - privacySettings: privacySettings ?? const ProfilePrivacySettings(), - createdAt: existingProfile?.createdAt ?? now, - updatedAt: now, - ); + return saved ?? updatedProfile; } /// Releases the rider's handle and cleans up the profile doc. @@ -197,6 +206,7 @@ class RiderProfileService { try { final userRef = firestore.collection('users').doc(uid); + final publicRef = firestore.collection('public_profiles').doc(uid); final doc = await userRef.get(); if (doc.exists && doc.data() != null) { final handle = doc.data()?['username'] as String?; @@ -205,6 +215,7 @@ class RiderProfileService { } } await userRef.delete(); + await publicRef.delete(); } catch (e) { debugPrint('[RiderProfileService] Failed to delete profile for $uid: $e'); rethrow; diff --git a/test/rider_profile_test.dart b/test/rider_profile_test.dart index 42d398a..f0f5459 100644 --- a/test/rider_profile_test.dart +++ b/test/rider_profile_test.dart @@ -63,6 +63,27 @@ void main() { expect(restored.avatarPreset, equals(profile.avatarPreset)); expect(restored.privacySettings.isProfilePublic, isFalse); expect(restored.displayHandle, equals('@apex_pilot')); + + // Public projection test + final publicJson = profile.toPublicProfileJson(); + expect(publicJson['id'], equals('user_123')); + expect(publicJson['username'], equals('apex_pilot')); + expect(publicJson['displayName'], equals('Apex Pilot')); + expect(publicJson['bio'], equals('Weekend track and mountain rider.')); + expect(publicJson['location'], equals('North Georgia Mountains')); + expect(publicJson['avatarPreset'], equals('helmet_cyan')); + expect(publicJson['isSearchableByHandle'], isTrue); + expect(publicJson['isProfilePublic'], isFalse); + expect(publicJson['allowFriendRequests'], isTrue); + // Ensure private keys are never included + expect(publicJson.containsKey('email'), isFalse); + expect(publicJson.containsKey('rides'), isFalse); + + final fromPublic = RiderProfile.fromPublicProfileJson(publicJson); + expect(fromPublic.uid, equals('user_123')); + expect(fromPublic.handle, equals('apex_pilot')); + expect(fromPublic.displayName, equals('Apex Pilot')); + expect(fromPublic.privacySettings.isProfilePublic, isFalse); }); test('FriendSummary serialization round-trip', () {