fix: harden rider social privacy and security

This commit is contained in:
Developer 2026-09-03 19:47:25 -04:00
parent 250c37711a
commit 0c2e1a0cac
6 changed files with 413 additions and 39 deletions

View File

@ -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',
}));
});
});

View File

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

View File

@ -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();

View File

@ -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<String, dynamic> 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<String, dynamic> 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<String, dynamic> json) {
final created = json['createdAt'] != null
? DateTime.tryParse(json['createdAt'] as String) ?? DateTime.now()

View File

@ -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<RiderProfile?> 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<RiderProfile?> 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;

View File

@ -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', () {