bulltrackers-module 1.0.614 → 1.0.616

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -529,8 +529,11 @@ const fetchUserVerificationData = async (firestore, userId) => {
529
529
  * Lookup CID by email (server-side only for security)
530
530
  * Searches through all SignedInUsers verification documents to find matching email
531
531
  * Returns only the CID and basic verification status - no email exposure
532
+ *
533
+ * NOTE: Multiple Firebase accounts (different UIDs) can access the same CID
534
+ * if their emails are in the verification document's email array.
532
535
  */
533
- const lookupCidByEmail = async (firestore, userEmail) => {
536
+ const lookupCidByEmail = async (firestore, userEmail, firebaseUid = null) => {
534
537
  if (!userEmail) {
535
538
  throw new Error('Email is required for lookup');
536
539
  }
@@ -556,6 +559,15 @@ const lookupCidByEmail = async (firestore, userEmail) => {
556
559
  // Case-insensitive email comparison
557
560
  const normalizedEmails = emails.map(e => String(e).toLowerCase().trim());
558
561
  if (normalizedEmails.includes(normalizedEmail)) {
562
+ // Optional: If Firebase UID is provided, validate it's in the firebaseUids array (if it exists)
563
+ // This provides additional security but doesn't block access if UID tracking isn't set up yet
564
+ if (firebaseUid && verificationData.firebaseUids && Array.isArray(verificationData.firebaseUids)) {
565
+ if (!verificationData.firebaseUids.includes(firebaseUid)) {
566
+ // UID not in list - log warning but still allow access (email is the source of truth)
567
+ console.warn(`[lookupCidByEmail] Email ${userEmail} matches CID ${userDoc.id} but Firebase UID ${firebaseUid} not in firebaseUids array`);
568
+ }
569
+ }
570
+
559
571
  // Found match - return only CID and verification status (no email)
560
572
  return {
561
573
  cid: verificationData.etoroCID || Number(userDoc.id),
@@ -1146,21 +1158,73 @@ const fetchNotifications = async (firestore, userId, options = {}) => {
1146
1158
 
1147
1159
  // 12. Mark Notification Read
1148
1160
  const markNotificationRead = async (firestore, userId, notificationIds, markAll = false) => {
1149
- const batch = firestore.batch();
1150
1161
  const collectionRef = firestore.collection('SignedInUsers').doc(userId).collection('notifications');
1162
+ const MAX_BATCH_SIZE = 500; // Firestore batch limit
1163
+ const batches = [];
1164
+ let currentBatch = firestore.batch();
1165
+ let batchCount = 0;
1151
1166
 
1152
1167
  if (markAll) {
1153
- const snapshot = await collectionRef.where('read', '==', false).get();
1168
+ // Query all unread notifications with a reasonable limit to prevent timeouts
1169
+ // Process in chunks to avoid overwhelming the system
1170
+ const MAX_NOTIFICATIONS_TO_PROCESS = 1000; // Limit to prevent timeouts
1171
+ const snapshot = await collectionRef.where('read', '==', false).limit(MAX_NOTIFICATIONS_TO_PROCESS).get();
1172
+ console.log(`[markNotificationRead] Marking ${snapshot.size} notifications as read for user ${userId} (limited to ${MAX_NOTIFICATIONS_TO_PROCESS})`);
1173
+
1174
+ if (snapshot.size === 0) {
1175
+ console.log(`[markNotificationRead] No unread notifications found for user ${userId}`);
1176
+ return;
1177
+ }
1178
+
1154
1179
  snapshot.docs.forEach(doc => {
1155
- batch.update(doc.ref, { read: true, readAt: new Date() });
1180
+ currentBatch.update(doc.ref, { read: true, readAt: new Date() });
1181
+ batchCount++;
1182
+
1183
+ // If batch is full, commit it and start a new one
1184
+ if (batchCount >= MAX_BATCH_SIZE) {
1185
+ batches.push(currentBatch);
1186
+ currentBatch = firestore.batch();
1187
+ batchCount = 0;
1188
+ }
1156
1189
  });
1157
- } else if (Array.isArray(notificationIds)) {
1190
+
1191
+ // Add the last batch if it has operations
1192
+ if (batchCount > 0) {
1193
+ batches.push(currentBatch);
1194
+ }
1195
+ } else if (Array.isArray(notificationIds) && notificationIds.length > 0) {
1196
+ // Mark specific notifications
1158
1197
  notificationIds.forEach(id => {
1159
1198
  const ref = collectionRef.doc(id);
1160
- batch.update(ref, { read: true, readAt: new Date() });
1199
+ currentBatch.update(ref, { read: true, readAt: new Date() });
1200
+ batchCount++;
1201
+
1202
+ // If batch is full, commit it and start a new one
1203
+ if (batchCount >= MAX_BATCH_SIZE) {
1204
+ batches.push(currentBatch);
1205
+ currentBatch = firestore.batch();
1206
+ batchCount = 0;
1207
+ }
1161
1208
  });
1209
+
1210
+ // Add the last batch if it has operations
1211
+ if (batchCount > 0) {
1212
+ batches.push(currentBatch);
1213
+ }
1214
+ } else {
1215
+ // No valid operation
1216
+ return;
1217
+ }
1218
+
1219
+ // Commit all batches sequentially to avoid overwhelming Firestore and prevent timeouts
1220
+ if (batches.length > 0) {
1221
+ console.log(`[markNotificationRead] Committing ${batches.length} batch(es) for user ${userId}`);
1222
+ for (let i = 0; i < batches.length; i++) {
1223
+ await batches[i].commit();
1224
+ console.log(`[markNotificationRead] Committed batch ${i + 1}/${batches.length} for user ${userId}`);
1225
+ }
1226
+ console.log(`[markNotificationRead] Successfully marked notifications as read for user ${userId}`);
1162
1227
  }
1163
- await batch.commit();
1164
1228
  };
1165
1229
 
1166
1230
 
@@ -2,6 +2,12 @@
2
2
  * Middleware to resolve the effective user ID, handling Developer Impersonation.
3
3
  * Sets req.targetUserId, req.isImpersonating, and req.actualUserId.
4
4
  *
5
+ * SECURITY: This middleware validates that the authenticated Firebase user owns the requested CID.
6
+ * It prevents IDOR (Insecure Direct Object Reference) attacks by:
7
+ * 1. Looking up the CID associated with the Firebase authenticated user's email
8
+ * 2. Forcing req.targetUserId to be the authenticated user's CID (unless developer impersonation)
9
+ * 3. Ignoring any userCid provided in headers/query/body if it doesn't match the authenticated user
10
+ *
5
11
  * Public routes (that don't require authentication):
6
12
  * - /watchlists/public
7
13
  * - /popular-investors/trending
@@ -9,7 +15,7 @@
9
15
  * - /popular-investors/master-list
10
16
  * - /popular-investors/search
11
17
  */
12
- const { isDeveloper } = require('../helpers/data-fetchers/firestore.js'); // Using your provided helper
18
+ const { isDeveloper, lookupCidByEmail } = require('../helpers/data-fetchers/firestore.js');
13
19
 
14
20
  // List of public routes that don't require userCid
15
21
  // Also includes routes that use Firebase Auth token authentication (like /verification/lookup)
@@ -60,6 +66,12 @@ const isPublicRoute = (path, originalUrl) => {
60
66
 
61
67
  const resolveUserIdentity = async (req, res, next) => {
62
68
  try {
69
+ const db = req.app.locals?.db || req.dependencies?.db;
70
+ if (!db) {
71
+ console.error('[IdentityMiddleware] Database not available');
72
+ return res.status(500).json({ error: "Internal server error" });
73
+ }
74
+
63
75
  // Check if this is a public route (check both path and originalUrl for Express routing)
64
76
  const isPublic = isPublicRoute(req.path, req.originalUrl);
65
77
 
@@ -68,45 +80,103 @@ const resolveUserIdentity = async (req, res, next) => {
68
80
  const hasFirebaseAuth = req.headers.authorization &&
69
81
  req.headers.authorization.startsWith('Bearer ');
70
82
 
71
- // 1. Identify the actual authenticated user (from Auth middleware or params)
72
- const actualUserId = req.query.userCid || req.body.userCid || req.headers['x-user-cid'];
83
+ // SECURITY FIX: If Firebase Auth is present, validate the user owns the requested CID
84
+ // NOTE: Multiple Firebase accounts (different UIDs) can access the same CID
85
+ // if their emails are in the verification document's email array
86
+ let authenticatedUserCid = null;
87
+ if (req.firebaseUser && req.firebaseUser.email) {
88
+ try {
89
+ // Look up the CID associated with the authenticated Firebase user's email
90
+ // Pass Firebase UID for optional validation (email is the source of truth)
91
+ const userData = await lookupCidByEmail(db, req.firebaseUser.email, req.firebaseUser.uid);
92
+ if (userData && userData.cid) {
93
+ authenticatedUserCid = String(userData.cid);
94
+ console.log(`[Identity] Authenticated user ${req.firebaseUser.email} (UID: ${req.firebaseUser.uid}) owns CID ${authenticatedUserCid}`);
95
+ } else {
96
+ console.warn(`[Identity] No CID found for authenticated user ${req.firebaseUser.email}`);
97
+ }
98
+ } catch (error) {
99
+ console.error(`[Identity] Error looking up CID for ${req.firebaseUser.email}:`, error);
100
+ // Continue - will fail validation below if CID is required
101
+ }
102
+ }
103
+
104
+ // 1. Identify the requested user ID (from params/headers/body)
105
+ const requestedUserId = req.query.userCid || req.body.userCid || req.headers['x-user-cid'];
73
106
 
74
- // For public routes or Firebase Auth routes, userCid is optional
75
- if (!actualUserId && !isPublic && !hasFirebaseAuth) {
107
+ // For public routes, userCid is optional
108
+ if (!requestedUserId && !isPublic && !hasFirebaseAuth) {
76
109
  return res.status(400).json({ error: "Missing user identification (userCid)" });
77
110
  }
78
111
 
79
- // If no user ID provided and it's a public route or uses Firebase Auth, skip identity resolution
80
- if (!actualUserId && (isPublic || hasFirebaseAuth)) {
112
+ // If no user ID provided and it's a public route, skip identity resolution
113
+ if (!requestedUserId && isPublic) {
81
114
  req.actualUserId = null;
82
115
  req.targetUserId = null;
83
116
  req.isImpersonating = false;
84
117
  return next();
85
118
  }
86
119
 
87
- // 2. Check for Impersonation Request (Headers or Query)
88
- const impersonateId = req.headers['x-impersonate-cid'] || req.query.impersonateCid;
89
-
90
- req.actualUserId = actualUserId;
91
- req.targetUserId = actualUserId; // Default to actual
92
- req.isImpersonating = false;
93
-
94
- // 3. specific logic for Developers
95
- if (impersonateId && impersonateId !== actualUserId) {
96
- // Verify if the ACTUAL user is a developer
97
- // We pass the db instance from dependencies (assuming standard express injection)
98
- const db = req.app.locals.db || req.dependencies.db;
99
- const isDev = await isDeveloper(db, actualUserId);
100
-
101
- if (isDev) {
102
- req.targetUserId = impersonateId;
103
- req.isImpersonating = true;
104
- console.log(`[Identity] Dev ${actualUserId} is impersonating ${impersonateId}`);
105
- } else {
106
- // Fail silently or warn? For security, maybe just ignore or strict fail.
107
- // Ignoring ensures they just see their own data.
108
- console.warn(`[Identity] Unauthorized impersonation attempt by ${actualUserId}`);
120
+ // SECURITY FIX: If Firebase Auth is present, enforce ownership
121
+ // The authenticated user can only access their own CID (unless developer impersonation)
122
+ if (authenticatedUserCid) {
123
+ // If a CID was requested but doesn't match the authenticated user's CID, reject (unless developer impersonation)
124
+ if (requestedUserId && requestedUserId !== authenticatedUserCid) {
125
+ // Check if this is a developer impersonation request
126
+ const impersonateId = req.headers['x-impersonate-cid'] || req.query.impersonateCid;
127
+
128
+ if (impersonateId && impersonateId === requestedUserId) {
129
+ // This is an explicit impersonation request - verify developer status
130
+ const isDev = await isDeveloper(db, authenticatedUserCid);
131
+ if (isDev) {
132
+ req.actualUserId = authenticatedUserCid;
133
+ req.targetUserId = impersonateId;
134
+ req.isImpersonating = true;
135
+ console.log(`[Identity] Dev ${authenticatedUserCid} is impersonating ${impersonateId}`);
136
+ return next();
137
+ } else {
138
+ console.warn(`[Identity] Unauthorized impersonation attempt by ${authenticatedUserCid}`);
139
+ return res.status(403).json({ error: "Unauthorized: Developer privileges required for impersonation" });
140
+ }
141
+ } else {
142
+ // User is trying to access another user's CID without impersonation header
143
+ console.warn(`[Identity] Security violation: User ${authenticatedUserCid} attempted to access CID ${requestedUserId}`);
144
+ return res.status(403).json({ error: "Unauthorized: You can only access your own data" });
145
+ }
146
+ }
147
+
148
+ // Authenticated user owns the requested CID (or no CID requested) - use authenticated CID
149
+ req.actualUserId = authenticatedUserCid;
150
+ req.targetUserId = authenticatedUserCid;
151
+ req.isImpersonating = false;
152
+ } else if (requestedUserId) {
153
+ // No Firebase Auth but CID provided - legacy behavior (for backward compatibility)
154
+ // WARNING: This is less secure but may be needed for some legacy clients
155
+ // Consider requiring Firebase Auth for all authenticated routes
156
+ if (!isPublic) {
157
+ console.warn(`[Identity] No Firebase Auth but CID ${requestedUserId} provided - using legacy mode`);
158
+ }
159
+ req.actualUserId = requestedUserId;
160
+ req.targetUserId = requestedUserId;
161
+ req.isImpersonating = false;
162
+
163
+ // Check for developer impersonation (legacy mode)
164
+ const impersonateId = req.headers['x-impersonate-cid'] || req.query.impersonateCid;
165
+ if (impersonateId && impersonateId !== requestedUserId) {
166
+ const isDev = await isDeveloper(db, requestedUserId);
167
+ if (isDev) {
168
+ req.targetUserId = impersonateId;
169
+ req.isImpersonating = true;
170
+ console.log(`[Identity] Dev ${requestedUserId} is impersonating ${impersonateId}`);
171
+ } else {
172
+ console.warn(`[Identity] Unauthorized impersonation attempt by ${requestedUserId}`);
173
+ }
109
174
  }
175
+ } else {
176
+ // No CID and not public - this shouldn't happen due to check above, but handle gracefully
177
+ req.actualUserId = null;
178
+ req.targetUserId = null;
179
+ req.isImpersonating = false;
110
180
  }
111
181
 
112
182
  next();
@@ -1,5 +1,6 @@
1
1
  const express = require('express');
2
2
  const { resolveUserIdentity } = require('../middleware/identity_middleware.js');
3
+ const { verifyFirebaseToken } = require('../middleware/firebase_auth_middleware.js');
3
4
 
4
5
  const notificationRoutes = require('./notifications.js');
5
6
  const alertsRoutes = require('./alerts.js'); // <--- NEW
@@ -19,6 +20,11 @@ module.exports = (dependencies) => {
19
20
  next();
20
21
  });
21
22
 
23
+ // SECURITY: Verify Firebase Auth token first (if present)
24
+ // This allows identity_middleware to validate CID ownership
25
+ router.use(verifyFirebaseToken);
26
+
27
+ // Then resolve user identity with CID ownership validation
22
28
  router.use(resolveUserIdentity);
23
29
 
24
30
  router.use('/notifications', notificationRoutes);
@@ -27,10 +27,14 @@ router.post('/mark-read', async (req, res) => {
27
27
  const { db } = req.dependencies;
28
28
  const { notificationIds, markAll } = req.body; // Array of IDs or boolean flag
29
29
 
30
+ console.log(`[notifications/mark-read] Request for user ${req.targetUserId}, markAll: ${markAll}, notificationIds: ${notificationIds?.length || 0}`);
31
+
32
+ // Execute the operation with a reasonable timeout
30
33
  await markNotificationRead(db, req.targetUserId, notificationIds, markAll);
31
34
 
32
35
  res.json({ success: true });
33
36
  } catch (error) {
37
+ console.error(`[notifications/mark-read] Error for user ${req.targetUserId}:`, error.message, error.stack);
34
38
  res.status(500).json({ error: error.message });
35
39
  }
36
40
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bulltrackers-module",
3
- "version": "1.0.614",
3
+ "version": "1.0.616",
4
4
  "description": "Helper Functions for Bulltrackers.",
5
5
  "main": "index.js",
6
6
  "files": [