bulltrackers-module 1.0.621 → 1.0.623
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.
- package/functions/api-v2/helpers/data-fetchers/firestore.js +166 -49
- package/functions/api-v2/helpers/security_utils.js +110 -0
- package/functions/api-v2/helpers/timeout_utils.js +42 -0
- package/functions/api-v2/index.js +12 -0
- package/functions/api-v2/middleware/error_handler.js +1 -0
- package/functions/api-v2/middleware/identity_middleware.js +2 -1
- package/functions/api-v2/routes/alerts.js +108 -40
- package/functions/api-v2/routes/notifications.js +40 -12
- package/functions/api-v2/routes/popular_investors.js +70 -26
- package/functions/api-v2/routes/profile.js +45 -13
- package/functions/api-v2/routes/settings.js +52 -14
- package/functions/api-v2/routes/sync.js +48 -9
- package/functions/api-v2/routes/watchlists.js +93 -26
- package/package.json +1 -1
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// Firestore helper functions for fetching data from collections
|
|
2
|
-
const { FieldValue } = require('@google-cloud/firestore');
|
|
2
|
+
const { FieldValue, Timestamp } = require('@google-cloud/firestore');
|
|
3
3
|
const { dispatchSyncRequest } = require('../task_engine_helper.js');
|
|
4
|
+
const { sanitizeCid, sanitizeDocId } = require('../security_utils.js');
|
|
4
5
|
const crypto = require('crypto');
|
|
5
6
|
|
|
6
7
|
// 1. Fetch latest stored snapshots of user data from a user-centric collection
|
|
@@ -34,11 +35,14 @@ const crypto = require('crypto');
|
|
|
34
35
|
*/
|
|
35
36
|
const latestUserCentricSnapshot = async (firestore, userId, collectionName, dataType, userType, documentName, fields = []) => {
|
|
36
37
|
try {
|
|
37
|
-
|
|
38
|
+
// Sanitize user inputs to prevent injection attacks
|
|
39
|
+
const sanitizedUserId = sanitizeCid(userId);
|
|
40
|
+
const sanitizedDocName = documentName ? sanitizeDocId(documentName) : null;
|
|
41
|
+
const baseRef = firestore.collection(userType).doc(sanitizedUserId).collection(collectionName);
|
|
38
42
|
|
|
39
|
-
if (
|
|
43
|
+
if (sanitizedDocName) {
|
|
40
44
|
// SCENARIO 1: Single Document
|
|
41
|
-
let docRef = baseRef.doc(
|
|
45
|
+
let docRef = baseRef.doc(sanitizedDocName);
|
|
42
46
|
// Apply field selection if fields are specified (Firestore projection)
|
|
43
47
|
if (fields.length > 0) {
|
|
44
48
|
docRef = docRef.select(...fields);
|
|
@@ -85,14 +89,14 @@ const pageCollection = async (firestore, dateStr, computationName, userId, lookb
|
|
|
85
89
|
const docRef = firestore.collection('unified_insights').doc(dateKey)
|
|
86
90
|
.collection('results').doc('popular-investor')
|
|
87
91
|
.collection('computations').doc(computationName)
|
|
88
|
-
.collection('pages').doc(
|
|
92
|
+
.collection('pages').doc(sanitizedUserId);
|
|
89
93
|
const docSnapshot = await docRef.get();
|
|
90
94
|
if (docSnapshot.exists) {
|
|
91
95
|
results.push({ date: dateKey, data: docSnapshot.data() });
|
|
92
96
|
}
|
|
93
97
|
}
|
|
94
98
|
if (results.length === 0) {
|
|
95
|
-
throw new Error(`No page data found for User ID ${
|
|
99
|
+
throw new Error(`No page data found for User ID ${sanitizedUserId} in computation ${sanitizedComputationName} within the last ${lookbackDays} days`);
|
|
96
100
|
}
|
|
97
101
|
// Sort results by date descending (newest first) so the latest data is always first
|
|
98
102
|
results.sort((a, b) => {
|
|
@@ -370,9 +374,12 @@ const manageUserWatchlist = async (db, userId, instruction, payload = {}) => {
|
|
|
370
374
|
const todayStr = new Date().toISOString().split('T')[0]; // "2026-01-09"
|
|
371
375
|
const batch = db.batch();
|
|
372
376
|
|
|
377
|
+
// Sanitize user inputs
|
|
378
|
+
const sanitizedUserId = sanitizeCid(userId);
|
|
379
|
+
|
|
373
380
|
// 1. Determine Watchlist ID and Reference
|
|
374
|
-
const watchlistId = payload.id
|
|
375
|
-
const userDocRef = db.collection('SignedInUsers').doc(
|
|
381
|
+
const watchlistId = payload.id ? sanitizeDocId(payload.id) : `watchlist_${Date.now()}_${Math.random().toString(16).substr(2, 8)}`;
|
|
382
|
+
const userDocRef = db.collection('SignedInUsers').doc(sanitizedUserId).collection('watchlists').doc(watchlistId);
|
|
376
383
|
|
|
377
384
|
try {
|
|
378
385
|
let addedItems = []; // Items newly added (needs Master Log + Current Counter Increment)
|
|
@@ -397,7 +404,7 @@ const manageUserWatchlist = async (db, userId, instruction, payload = {}) => {
|
|
|
397
404
|
const newDocData = {
|
|
398
405
|
...payload,
|
|
399
406
|
id: watchlistId,
|
|
400
|
-
createdBy:
|
|
407
|
+
createdBy: sanitizedUserId,
|
|
401
408
|
createdAt: FieldValue.serverTimestamp(),
|
|
402
409
|
updatedAt: FieldValue.serverTimestamp(),
|
|
403
410
|
copyCount: 0,
|
|
@@ -447,7 +454,7 @@ const manageUserWatchlist = async (db, userId, instruction, payload = {}) => {
|
|
|
447
454
|
totalUsers: FieldValue.increment(1),
|
|
448
455
|
privateWatchlistCount: FieldValue.increment(isPrivate ? 1 : 0),
|
|
449
456
|
publicWatchlistCount: FieldValue.increment(isPrivate ? 0 : 1),
|
|
450
|
-
users: FieldValue.arrayUnion(
|
|
457
|
+
users: FieldValue.arrayUnion(sanitizedUserId)
|
|
451
458
|
}, { merge: true });
|
|
452
459
|
|
|
453
460
|
// B. Update PI Real-time Counter (Increment)
|
|
@@ -459,12 +466,12 @@ const manageUserWatchlist = async (db, userId, instruction, payload = {}) => {
|
|
|
459
466
|
batch.set(piCounterRef, {
|
|
460
467
|
lastUpdated: FieldValue.serverTimestamp(),
|
|
461
468
|
totalUsers: FieldValue.increment(1),
|
|
462
|
-
userCids: FieldValue.arrayUnion(
|
|
469
|
+
userCids: FieldValue.arrayUnion(sanitizedUserId),
|
|
463
470
|
// Nested Map Update for Daily stats
|
|
464
471
|
[dailyField]: {
|
|
465
472
|
count: FieldValue.increment(1),
|
|
466
473
|
timestamp: FieldValue.serverTimestamp(),
|
|
467
|
-
userCids: FieldValue.arrayUnion(
|
|
474
|
+
userCids: FieldValue.arrayUnion(sanitizedUserId)
|
|
468
475
|
}
|
|
469
476
|
}, { merge: true });
|
|
470
477
|
});
|
|
@@ -618,10 +625,20 @@ const lookupCidByEmail = async (firestore, userEmail, firebaseUid = null) => {
|
|
|
618
625
|
const normalizedEmails = emails.map(e => String(e).toLowerCase().trim());
|
|
619
626
|
|
|
620
627
|
if (normalizedEmails.includes(normalizedEmail)) {
|
|
621
|
-
//
|
|
622
|
-
if (firebaseUid
|
|
623
|
-
if (
|
|
624
|
-
|
|
628
|
+
// Validate and enforce Firebase UID if provided
|
|
629
|
+
if (firebaseUid) {
|
|
630
|
+
if (verificationData.firebaseUids && Array.isArray(verificationData.firebaseUids)) {
|
|
631
|
+
// New users: strict enforcement
|
|
632
|
+
if (!verificationData.firebaseUids.includes(firebaseUid)) {
|
|
633
|
+
throw new Error(`Firebase UID not authorized for this CID`);
|
|
634
|
+
}
|
|
635
|
+
} else {
|
|
636
|
+
// Legacy users: populate firebaseUids array (migration)
|
|
637
|
+
// Note: A single CID can have multiple emails/UIDs for different accounts
|
|
638
|
+
await verificationRef.update({
|
|
639
|
+
firebaseUids: FieldValue.arrayUnion(firebaseUid)
|
|
640
|
+
});
|
|
641
|
+
console.log(`[Migration] Added Firebase UID ${firebaseUid} to CID ${cid}`);
|
|
625
642
|
}
|
|
626
643
|
}
|
|
627
644
|
|
|
@@ -666,10 +683,20 @@ const lookupCidByEmail = async (firestore, userEmail, firebaseUid = null) => {
|
|
|
666
683
|
if (normalizedEmails.includes(normalizedEmail)) {
|
|
667
684
|
const cid = verificationData.etoroCID || Number(userDoc.id);
|
|
668
685
|
|
|
669
|
-
//
|
|
670
|
-
if (firebaseUid
|
|
671
|
-
if (
|
|
672
|
-
|
|
686
|
+
// Validate and enforce Firebase UID if provided
|
|
687
|
+
if (firebaseUid) {
|
|
688
|
+
if (verificationData.firebaseUids && Array.isArray(verificationData.firebaseUids)) {
|
|
689
|
+
// New users: strict enforcement
|
|
690
|
+
if (!verificationData.firebaseUids.includes(firebaseUid)) {
|
|
691
|
+
throw new Error(`Firebase UID not authorized for this CID`);
|
|
692
|
+
}
|
|
693
|
+
} else {
|
|
694
|
+
// Legacy users: populate firebaseUids array (migration)
|
|
695
|
+
// Note: A single CID can have multiple emails/UIDs for different accounts
|
|
696
|
+
await verificationRef.update({
|
|
697
|
+
firebaseUids: FieldValue.arrayUnion(firebaseUid)
|
|
698
|
+
});
|
|
699
|
+
console.log(`[Migration] Added Firebase UID ${firebaseUid} to CID ${cid}`);
|
|
673
700
|
}
|
|
674
701
|
}
|
|
675
702
|
|
|
@@ -734,8 +761,7 @@ const requestPopularInvestorAddition = async (firestore, userId, piId, piUsernam
|
|
|
734
761
|
piUsername,
|
|
735
762
|
requestedBy: userId,
|
|
736
763
|
requestedAt: FieldValue.serverTimestamp(),
|
|
737
|
-
ttl:
|
|
738
|
-
|
|
764
|
+
ttl: Timestamp.fromDate(ttlDate)
|
|
739
765
|
});
|
|
740
766
|
const globalRequestRef = firestore.collection('PiAdditionRequestLogs').doc(todayStr).collection('requests').doc(piId);
|
|
741
767
|
await globalRequestRef.set({
|
|
@@ -933,14 +959,18 @@ const hasUserCopiedPopularInvestor = async (firestore, userId, popularInvestorId
|
|
|
933
959
|
// 3. A GLOBAL REVIEWS COLLECTION LIKE /PiReviews/YYYY-MM-DD/document_name containing a map of the reviews made each day, who made them, for which PI and text content
|
|
934
960
|
// 4. We need a migration strategy to pull across the existing stored data from the old collection path into the new collection path upon read.
|
|
935
961
|
const manageReviews = async (db, userId, action, params = {}) => {
|
|
962
|
+
// Sanitize user inputs
|
|
963
|
+
const sanitizedUserId = sanitizeCid(userId);
|
|
936
964
|
const { piId } = params;
|
|
965
|
+
const sanitizedPiId = sanitizeCid(piId);
|
|
966
|
+
|
|
937
967
|
const batch = db.batch();
|
|
938
968
|
const todayStr = new Date().toISOString().split('T')[0];
|
|
939
969
|
|
|
940
|
-
// Paths
|
|
941
|
-
const newReviewPath = db.collection('SignedInUsers').doc(
|
|
942
|
-
const piReviewPath = db.collection('PopularInvestors').doc(
|
|
943
|
-
const legacyReviewPath = db.collection('pi_reviews').doc(`${
|
|
970
|
+
// Paths - all user inputs sanitized
|
|
971
|
+
const newReviewPath = db.collection('SignedInUsers').doc(sanitizedUserId).collection('reviews').doc(sanitizedPiId);
|
|
972
|
+
const piReviewPath = db.collection('PopularInvestors').doc(sanitizedPiId).collection('reviews').doc(`${sanitizedUserId}_${sanitizedPiId}`);
|
|
973
|
+
const legacyReviewPath = db.collection('pi_reviews').doc(`${sanitizedUserId}_${sanitizedPiId}`);
|
|
944
974
|
const globalLogPath = db.collection('PiReviews').doc(todayStr).collection('shards').doc('daily_log');
|
|
945
975
|
|
|
946
976
|
try {
|
|
@@ -2048,9 +2078,87 @@ const searchPopularInvestors = async (db, queryStr) => {
|
|
|
2048
2078
|
// NEW: SYNC & RATE LIMITS (Rec 7, 9, 15)
|
|
2049
2079
|
// ==========================================
|
|
2050
2080
|
|
|
2051
|
-
const checkSyncRateLimits = async (db, targetId, requesterId, isDev) => {
|
|
2052
|
-
|
|
2081
|
+
const checkSyncRateLimits = async (db, targetId, requesterId, isDev, maxRequests = 1, windowMs = 6 * 60 * 60 * 1000) => {
|
|
2082
|
+
// Developers get higher limits (100 requests per hour globally, 10 per target) but still limited
|
|
2083
|
+
if (isDev) {
|
|
2084
|
+
const DEV_GLOBAL_LIMIT_MS = 60 * 60 * 1000; // 1 hour
|
|
2085
|
+
const DEV_GLOBAL_MAX_REQUESTS = 100; // Total requests per hour
|
|
2086
|
+
const DEV_PER_TARGET_LIMIT_MS = 60 * 60 * 1000; // 1 hour
|
|
2087
|
+
const DEV_PER_TARGET_MAX_REQUESTS = 10; // Requests per target per hour
|
|
2088
|
+
const now = Date.now();
|
|
2089
|
+
|
|
2090
|
+
// Check global developer limit (total requests across all targets)
|
|
2091
|
+
const globalDevRef = db.collection('developer_rate_limits').doc(String(requesterId)).collection('global').doc('latest');
|
|
2092
|
+
const globalDevSnap = await globalDevRef.get();
|
|
2093
|
+
|
|
2094
|
+
let globalRequestCount = 0;
|
|
2095
|
+
let globalWindowStart = now;
|
|
2096
|
+
|
|
2097
|
+
if (globalDevSnap.exists) {
|
|
2098
|
+
const globalData = globalDevSnap.data();
|
|
2099
|
+
globalRequestCount = globalData.requestCount || 0;
|
|
2100
|
+
globalWindowStart = globalData.windowStart?.toMillis() || now;
|
|
2101
|
+
|
|
2102
|
+
// Reset window if expired
|
|
2103
|
+
if (now - globalWindowStart >= DEV_GLOBAL_LIMIT_MS) {
|
|
2104
|
+
globalRequestCount = 0;
|
|
2105
|
+
globalWindowStart = now;
|
|
2106
|
+
}
|
|
2107
|
+
}
|
|
2108
|
+
|
|
2109
|
+
// Check per-target limit (prevent spamming specific users)
|
|
2110
|
+
const sanitizedTargetId = sanitizeCid(targetId);
|
|
2111
|
+
const perTargetRef = db.collection('user_sync_requests').doc(sanitizedTargetId).collection('developer_limits').doc(String(requesterId));
|
|
2112
|
+
const perTargetSnap = await perTargetRef.get();
|
|
2113
|
+
|
|
2114
|
+
let perTargetCount = 0;
|
|
2115
|
+
let perTargetWindowStart = now;
|
|
2116
|
+
|
|
2117
|
+
if (perTargetSnap.exists) {
|
|
2118
|
+
const perTargetData = perTargetSnap.data();
|
|
2119
|
+
perTargetCount = perTargetData.requestCount || 0;
|
|
2120
|
+
perTargetWindowStart = perTargetData.windowStart?.toMillis() || now;
|
|
2121
|
+
|
|
2122
|
+
// Reset window if expired
|
|
2123
|
+
if (now - perTargetWindowStart >= DEV_PER_TARGET_LIMIT_MS) {
|
|
2124
|
+
perTargetCount = 0;
|
|
2125
|
+
perTargetWindowStart = now;
|
|
2126
|
+
}
|
|
2127
|
+
|
|
2128
|
+
// Check per-target limit
|
|
2129
|
+
if (perTargetCount >= DEV_PER_TARGET_MAX_REQUESTS) {
|
|
2130
|
+
const waitMinutes = Math.ceil((perTargetWindowStart + DEV_PER_TARGET_LIMIT_MS - now) / 60000);
|
|
2131
|
+
return {
|
|
2132
|
+
allowed: false,
|
|
2133
|
+
message: `Developer rate limit exceeded for this target. Try again in ${waitMinutes} minutes.`
|
|
2134
|
+
};
|
|
2135
|
+
}
|
|
2136
|
+
}
|
|
2137
|
+
|
|
2138
|
+
// Check global limit
|
|
2139
|
+
if (globalRequestCount >= DEV_GLOBAL_MAX_REQUESTS) {
|
|
2140
|
+
const waitMinutes = Math.ceil((globalWindowStart + DEV_GLOBAL_LIMIT_MS - now) / 60000);
|
|
2141
|
+
return {
|
|
2142
|
+
allowed: false,
|
|
2143
|
+
message: `Developer global rate limit exceeded. Try again in ${waitMinutes} minutes.`
|
|
2144
|
+
};
|
|
2145
|
+
}
|
|
2146
|
+
|
|
2147
|
+
// Update both counters
|
|
2148
|
+
await globalDevRef.set({
|
|
2149
|
+
requestCount: globalRequestCount + 1,
|
|
2150
|
+
windowStart: Timestamp.fromMillis(globalWindowStart)
|
|
2151
|
+
}, { merge: true });
|
|
2152
|
+
|
|
2153
|
+
await perTargetRef.set({
|
|
2154
|
+
requestCount: perTargetCount + 1,
|
|
2155
|
+
windowStart: Timestamp.fromMillis(perTargetWindowStart)
|
|
2156
|
+
}, { merge: true });
|
|
2157
|
+
|
|
2158
|
+
return { allowed: true };
|
|
2159
|
+
}
|
|
2053
2160
|
|
|
2161
|
+
// Regular users: 1 request per 6 hours
|
|
2054
2162
|
const RATE_LIMIT_MS = 6 * 60 * 60 * 1000; // 6 hours
|
|
2055
2163
|
const now = Date.now();
|
|
2056
2164
|
|
|
@@ -2681,33 +2789,42 @@ const getWatchlistTriggerCounts = async (db, userId, watchlistId) => {
|
|
|
2681
2789
|
const piCid = String(item.cid);
|
|
2682
2790
|
let totalCount = 0;
|
|
2683
2791
|
|
|
2684
|
-
// Check each of the last 7 days
|
|
2685
|
-
|
|
2686
|
-
|
|
2687
|
-
|
|
2792
|
+
// Check each of the last 7 days - PARALLELIZED to fix N+1 query problem
|
|
2793
|
+
const dayDocs = await Promise.all(
|
|
2794
|
+
dates.map(dateStr =>
|
|
2795
|
+
db.collection('PIAlertHistoryData')
|
|
2688
2796
|
.doc(dateStr)
|
|
2689
|
-
.get()
|
|
2797
|
+
.get()
|
|
2798
|
+
.catch(e => {
|
|
2799
|
+
console.warn(`Error fetching date ${dateStr} for PI ${piCid}: ${e.message}`);
|
|
2800
|
+
return null; // Return null on error instead of throwing
|
|
2801
|
+
})
|
|
2802
|
+
)
|
|
2803
|
+
);
|
|
2804
|
+
|
|
2805
|
+
// Process all day documents
|
|
2806
|
+
for (const dayDoc of dayDocs) {
|
|
2807
|
+
if (!dayDoc || !dayDoc.exists) continue;
|
|
2808
|
+
|
|
2809
|
+
try {
|
|
2810
|
+
const dayData = dayDoc.data();
|
|
2811
|
+
const piData = dayData[piCid];
|
|
2690
2812
|
|
|
2691
|
-
if (
|
|
2692
|
-
|
|
2693
|
-
const piData
|
|
2694
|
-
|
|
2695
|
-
|
|
2696
|
-
// Check
|
|
2697
|
-
|
|
2698
|
-
|
|
2699
|
-
|
|
2700
|
-
// Check if triggeredFor array contains this user
|
|
2701
|
-
const triggeredFor = alertData.triggeredFor || [];
|
|
2702
|
-
if (Array.isArray(triggeredFor) && triggeredFor.includes(userCidStr)) {
|
|
2703
|
-
totalCount += alertData.count || 1; // Count each trigger
|
|
2704
|
-
}
|
|
2813
|
+
if (piData) {
|
|
2814
|
+
// Check all alert types for this PI
|
|
2815
|
+
for (const [alertType, alertData] of Object.entries(piData)) {
|
|
2816
|
+
if (alertType === 'lastUpdated' || typeof alertData !== 'object') continue;
|
|
2817
|
+
|
|
2818
|
+
// Check if triggeredFor array contains this user
|
|
2819
|
+
const triggeredFor = alertData.triggeredFor || [];
|
|
2820
|
+
if (Array.isArray(triggeredFor) && triggeredFor.includes(userCidStr)) {
|
|
2821
|
+
totalCount += alertData.count || 1; // Count each trigger
|
|
2705
2822
|
}
|
|
2706
2823
|
}
|
|
2707
2824
|
}
|
|
2708
2825
|
} catch (e) {
|
|
2709
|
-
// Skip this day if error
|
|
2710
|
-
console.warn(`Error
|
|
2826
|
+
// Skip this day if error processing
|
|
2827
|
+
console.warn(`Error processing day data for PI ${piCid}: ${e.message}`);
|
|
2711
2828
|
}
|
|
2712
2829
|
}
|
|
2713
2830
|
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Security utility functions for input validation and sanitization
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Sanitizes a CID (Customer ID) to prevent injection attacks
|
|
7
|
+
* @param {string|number} cid - The CID to sanitize
|
|
8
|
+
* @returns {string} - Sanitized CID
|
|
9
|
+
* @throws {Error} - If CID is invalid
|
|
10
|
+
*/
|
|
11
|
+
function sanitizeCid(cid) {
|
|
12
|
+
if (cid === null || cid === undefined) {
|
|
13
|
+
throw new Error('CID is required');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Convert to string and remove all non-numeric characters
|
|
17
|
+
const sanitized = String(cid).replace(/[^0-9]/g, '');
|
|
18
|
+
|
|
19
|
+
// Validate length and format
|
|
20
|
+
if (!sanitized || sanitized.length === 0) {
|
|
21
|
+
throw new Error('Invalid CID format: must contain at least one digit');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
if (sanitized.length > 20) {
|
|
25
|
+
throw new Error('Invalid CID format: too long (max 20 digits)');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return sanitized;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Sanitizes a document ID to prevent path traversal attacks
|
|
33
|
+
* @param {string} docId - The document ID to sanitize
|
|
34
|
+
* @returns {string} - Sanitized document ID
|
|
35
|
+
* @throws {Error} - If document ID is invalid
|
|
36
|
+
*/
|
|
37
|
+
function sanitizeDocId(docId) {
|
|
38
|
+
if (!docId || typeof docId !== 'string') {
|
|
39
|
+
throw new Error('Document ID is required and must be a string');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Remove path traversal attempts and other dangerous characters
|
|
43
|
+
const sanitized = docId
|
|
44
|
+
.replace(/\.\./g, '') // Remove .. (path traversal)
|
|
45
|
+
.replace(/\//g, '') // Remove / (path separator)
|
|
46
|
+
.replace(/\\/g, '') // Remove \ (path separator)
|
|
47
|
+
.trim();
|
|
48
|
+
|
|
49
|
+
if (!sanitized || sanitized.length === 0) {
|
|
50
|
+
throw new Error('Invalid document ID: empty after sanitization');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (sanitized.length > 200) {
|
|
54
|
+
throw new Error('Invalid document ID: too long (max 200 characters)');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return sanitized;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Validates and sanitizes a username
|
|
62
|
+
* @param {string} username - The username to validate
|
|
63
|
+
* @returns {string} - Sanitized username
|
|
64
|
+
* @throws {Error} - If username is invalid
|
|
65
|
+
*/
|
|
66
|
+
function sanitizeUsername(username) {
|
|
67
|
+
if (!username || typeof username !== 'string') {
|
|
68
|
+
throw new Error('Username is required and must be a string');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const sanitized = username.trim();
|
|
72
|
+
|
|
73
|
+
if (sanitized.length === 0) {
|
|
74
|
+
throw new Error('Username cannot be empty');
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (sanitized.length > 100) {
|
|
78
|
+
throw new Error('Username too long (max 100 characters)');
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Allow alphanumeric, underscore, hyphen, and dot
|
|
82
|
+
if (!/^[a-zA-Z0-9._-]+$/.test(sanitized)) {
|
|
83
|
+
throw new Error('Username contains invalid characters');
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return sanitized;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Validates batch operation size
|
|
91
|
+
* @param {number} size - The size of the batch operation
|
|
92
|
+
* @param {number} maxSize - Maximum allowed size (default: 1000)
|
|
93
|
+
* @throws {Error} - If batch size exceeds limit
|
|
94
|
+
*/
|
|
95
|
+
function validateBatchSize(size, maxSize = 1000) {
|
|
96
|
+
if (typeof size !== 'number' || size < 0) {
|
|
97
|
+
throw new Error('Batch size must be a non-negative number');
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (size > maxSize) {
|
|
101
|
+
throw new Error(`Batch operation too large. Maximum ${maxSize} operations allowed, got ${size}`);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
module.exports = {
|
|
106
|
+
sanitizeCid,
|
|
107
|
+
sanitizeDocId,
|
|
108
|
+
sanitizeUsername,
|
|
109
|
+
validateBatchSize
|
|
110
|
+
};
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Utility functions for request timeout protection
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Wraps a promise with a timeout
|
|
7
|
+
* @param {Promise} promise - The promise to wrap
|
|
8
|
+
* @param {number} ms - Timeout in milliseconds (default: 10000)
|
|
9
|
+
* @param {string} errorMessage - Custom error message (optional)
|
|
10
|
+
* @returns {Promise} - Promise that rejects on timeout
|
|
11
|
+
*/
|
|
12
|
+
function withTimeout(promise, ms = 10000, errorMessage = 'Request timeout') {
|
|
13
|
+
return Promise.race([
|
|
14
|
+
promise,
|
|
15
|
+
new Promise((_, reject) =>
|
|
16
|
+
setTimeout(() => reject(new Error(`${errorMessage} (${ms}ms)`)), ms)
|
|
17
|
+
)
|
|
18
|
+
]);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Creates a timeout wrapper with a specific duration
|
|
23
|
+
* @param {number} ms - Timeout in milliseconds
|
|
24
|
+
* @returns {Function} - Function that wraps promises with the specified timeout
|
|
25
|
+
*/
|
|
26
|
+
function createTimeoutWrapper(ms) {
|
|
27
|
+
return (promise, errorMessage) => withTimeout(promise, ms, errorMessage);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Pre-configured timeout wrappers for common use cases
|
|
31
|
+
const timeouts = {
|
|
32
|
+
short: createTimeoutWrapper(5000), // 5 seconds
|
|
33
|
+
medium: createTimeoutWrapper(15000), // 15 seconds
|
|
34
|
+
long: createTimeoutWrapper(30000), // 30 seconds
|
|
35
|
+
veryLong: createTimeoutWrapper(60000) // 60 seconds
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
module.exports = {
|
|
39
|
+
withTimeout,
|
|
40
|
+
createTimeoutWrapper,
|
|
41
|
+
timeouts
|
|
42
|
+
};
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
const express = require('express');
|
|
7
7
|
const cors = require('cors');
|
|
8
8
|
const rateLimit = require('express-rate-limit');
|
|
9
|
+
const { v4: uuidv4 } = require('uuid');
|
|
9
10
|
const createRouter = require('./routes/index.js');
|
|
10
11
|
const errorHandler = require('./middleware/error_handler.js');
|
|
11
12
|
|
|
@@ -27,6 +28,10 @@ function createApiV2App(config, dependencies) {
|
|
|
27
28
|
const app = express();
|
|
28
29
|
const { logger } = dependencies;
|
|
29
30
|
|
|
31
|
+
// Trust proxy - Required when behind a load balancer/proxy (e.g., Google Cloud Functions)
|
|
32
|
+
// This allows express-rate-limit to correctly identify client IPs from X-Forwarded-For headers
|
|
33
|
+
app.set('trust proxy', true);
|
|
34
|
+
|
|
30
35
|
// CORS Configuration - Restrict to specific origins
|
|
31
36
|
app.use(cors({
|
|
32
37
|
origin: function (origin, callback) {
|
|
@@ -66,6 +71,13 @@ function createApiV2App(config, dependencies) {
|
|
|
66
71
|
app.use('/verification', authLimiter);
|
|
67
72
|
app.use('/', apiLimiter);
|
|
68
73
|
|
|
74
|
+
// Request ID tracking middleware (for debugging and error tracking)
|
|
75
|
+
app.use((req, res, next) => {
|
|
76
|
+
req.requestId = uuidv4();
|
|
77
|
+
res.setHeader('X-Request-ID', req.requestId);
|
|
78
|
+
next();
|
|
79
|
+
});
|
|
80
|
+
|
|
69
81
|
app.use(express.json());
|
|
70
82
|
|
|
71
83
|
// Health Check
|
|
@@ -113,7 +113,8 @@ const resolveUserIdentity = async (req, res, next) => {
|
|
|
113
113
|
// SECURITY: For private routes, require Firebase Auth to prevent IDOR attacks
|
|
114
114
|
if (!isPublic && !authenticatedUserCid && !hasFirebaseAuth) {
|
|
115
115
|
// Private route without authentication - reject immediately
|
|
116
|
-
|
|
116
|
+
// This is expected behavior - the security system is working correctly
|
|
117
|
+
console.log(`[Identity] Rejected unauthorized access to private route ${req.path} (no Firebase Auth token provided)`);
|
|
117
118
|
return res.status(401).json({
|
|
118
119
|
error: "Authentication required. Please provide a valid Firebase ID token in the Authorization header."
|
|
119
120
|
});
|