bulltrackers-module 1.0.622 → 1.0.624
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 +171 -50
- 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 +11 -1
- package/functions/api-v2/middleware/error_handler.js +1 -0
- 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);
|
|
@@ -76,6 +80,10 @@ const latestUserCentricSnapshot = async (firestore, userId, collectionName, data
|
|
|
76
80
|
|
|
77
81
|
const pageCollection = async (firestore, dateStr, computationName, userId, lookbackDays = 7) => {
|
|
78
82
|
try {
|
|
83
|
+
// Sanitize user inputs
|
|
84
|
+
const sanitizedUserId = sanitizeCid(userId);
|
|
85
|
+
const sanitizedComputationName = sanitizeDocId(computationName);
|
|
86
|
+
|
|
79
87
|
const endDate = new Date(dateStr);
|
|
80
88
|
const startDate = new Date(endDate);
|
|
81
89
|
startDate.setDate(endDate.getDate() - lookbackDays);
|
|
@@ -84,15 +92,15 @@ const pageCollection = async (firestore, dateStr, computationName, userId, lookb
|
|
|
84
92
|
const dateKey = d.toISOString().split('T')[0];
|
|
85
93
|
const docRef = firestore.collection('unified_insights').doc(dateKey)
|
|
86
94
|
.collection('results').doc('popular-investor')
|
|
87
|
-
.collection('computations').doc(
|
|
88
|
-
.collection('pages').doc(
|
|
95
|
+
.collection('computations').doc(sanitizedComputationName)
|
|
96
|
+
.collection('pages').doc(sanitizedUserId);
|
|
89
97
|
const docSnapshot = await docRef.get();
|
|
90
98
|
if (docSnapshot.exists) {
|
|
91
99
|
results.push({ date: dateKey, data: docSnapshot.data() });
|
|
92
100
|
}
|
|
93
101
|
}
|
|
94
102
|
if (results.length === 0) {
|
|
95
|
-
throw new Error(`No page data found for User ID ${
|
|
103
|
+
throw new Error(`No page data found for User ID ${sanitizedUserId} in computation ${sanitizedComputationName} within the last ${lookbackDays} days`);
|
|
96
104
|
}
|
|
97
105
|
// Sort results by date descending (newest first) so the latest data is always first
|
|
98
106
|
results.sort((a, b) => {
|
|
@@ -370,9 +378,12 @@ const manageUserWatchlist = async (db, userId, instruction, payload = {}) => {
|
|
|
370
378
|
const todayStr = new Date().toISOString().split('T')[0]; // "2026-01-09"
|
|
371
379
|
const batch = db.batch();
|
|
372
380
|
|
|
381
|
+
// Sanitize user inputs
|
|
382
|
+
const sanitizedUserId = sanitizeCid(userId);
|
|
383
|
+
|
|
373
384
|
// 1. Determine Watchlist ID and Reference
|
|
374
|
-
const watchlistId = payload.id
|
|
375
|
-
const userDocRef = db.collection('SignedInUsers').doc(
|
|
385
|
+
const watchlistId = payload.id ? sanitizeDocId(payload.id) : `watchlist_${Date.now()}_${Math.random().toString(16).substr(2, 8)}`;
|
|
386
|
+
const userDocRef = db.collection('SignedInUsers').doc(sanitizedUserId).collection('watchlists').doc(watchlistId);
|
|
376
387
|
|
|
377
388
|
try {
|
|
378
389
|
let addedItems = []; // Items newly added (needs Master Log + Current Counter Increment)
|
|
@@ -397,7 +408,7 @@ const manageUserWatchlist = async (db, userId, instruction, payload = {}) => {
|
|
|
397
408
|
const newDocData = {
|
|
398
409
|
...payload,
|
|
399
410
|
id: watchlistId,
|
|
400
|
-
createdBy:
|
|
411
|
+
createdBy: sanitizedUserId,
|
|
401
412
|
createdAt: FieldValue.serverTimestamp(),
|
|
402
413
|
updatedAt: FieldValue.serverTimestamp(),
|
|
403
414
|
copyCount: 0,
|
|
@@ -447,7 +458,7 @@ const manageUserWatchlist = async (db, userId, instruction, payload = {}) => {
|
|
|
447
458
|
totalUsers: FieldValue.increment(1),
|
|
448
459
|
privateWatchlistCount: FieldValue.increment(isPrivate ? 1 : 0),
|
|
449
460
|
publicWatchlistCount: FieldValue.increment(isPrivate ? 0 : 1),
|
|
450
|
-
users: FieldValue.arrayUnion(
|
|
461
|
+
users: FieldValue.arrayUnion(sanitizedUserId)
|
|
451
462
|
}, { merge: true });
|
|
452
463
|
|
|
453
464
|
// B. Update PI Real-time Counter (Increment)
|
|
@@ -459,12 +470,12 @@ const manageUserWatchlist = async (db, userId, instruction, payload = {}) => {
|
|
|
459
470
|
batch.set(piCounterRef, {
|
|
460
471
|
lastUpdated: FieldValue.serverTimestamp(),
|
|
461
472
|
totalUsers: FieldValue.increment(1),
|
|
462
|
-
userCids: FieldValue.arrayUnion(
|
|
473
|
+
userCids: FieldValue.arrayUnion(sanitizedUserId),
|
|
463
474
|
// Nested Map Update for Daily stats
|
|
464
475
|
[dailyField]: {
|
|
465
476
|
count: FieldValue.increment(1),
|
|
466
477
|
timestamp: FieldValue.serverTimestamp(),
|
|
467
|
-
userCids: FieldValue.arrayUnion(
|
|
478
|
+
userCids: FieldValue.arrayUnion(sanitizedUserId)
|
|
468
479
|
}
|
|
469
480
|
}, { merge: true });
|
|
470
481
|
});
|
|
@@ -618,10 +629,20 @@ const lookupCidByEmail = async (firestore, userEmail, firebaseUid = null) => {
|
|
|
618
629
|
const normalizedEmails = emails.map(e => String(e).toLowerCase().trim());
|
|
619
630
|
|
|
620
631
|
if (normalizedEmails.includes(normalizedEmail)) {
|
|
621
|
-
//
|
|
622
|
-
if (firebaseUid
|
|
623
|
-
if (
|
|
624
|
-
|
|
632
|
+
// Validate and enforce Firebase UID if provided
|
|
633
|
+
if (firebaseUid) {
|
|
634
|
+
if (verificationData.firebaseUids && Array.isArray(verificationData.firebaseUids)) {
|
|
635
|
+
// New users: strict enforcement
|
|
636
|
+
if (!verificationData.firebaseUids.includes(firebaseUid)) {
|
|
637
|
+
throw new Error(`Firebase UID not authorized for this CID`);
|
|
638
|
+
}
|
|
639
|
+
} else {
|
|
640
|
+
// Legacy users: populate firebaseUids array (migration)
|
|
641
|
+
// Note: A single CID can have multiple emails/UIDs for different accounts
|
|
642
|
+
await verificationRef.update({
|
|
643
|
+
firebaseUids: FieldValue.arrayUnion(firebaseUid)
|
|
644
|
+
});
|
|
645
|
+
console.log(`[Migration] Added Firebase UID ${firebaseUid} to CID ${cid}`);
|
|
625
646
|
}
|
|
626
647
|
}
|
|
627
648
|
|
|
@@ -666,10 +687,20 @@ const lookupCidByEmail = async (firestore, userEmail, firebaseUid = null) => {
|
|
|
666
687
|
if (normalizedEmails.includes(normalizedEmail)) {
|
|
667
688
|
const cid = verificationData.etoroCID || Number(userDoc.id);
|
|
668
689
|
|
|
669
|
-
//
|
|
670
|
-
if (firebaseUid
|
|
671
|
-
if (
|
|
672
|
-
|
|
690
|
+
// Validate and enforce Firebase UID if provided
|
|
691
|
+
if (firebaseUid) {
|
|
692
|
+
if (verificationData.firebaseUids && Array.isArray(verificationData.firebaseUids)) {
|
|
693
|
+
// New users: strict enforcement
|
|
694
|
+
if (!verificationData.firebaseUids.includes(firebaseUid)) {
|
|
695
|
+
throw new Error(`Firebase UID not authorized for this CID`);
|
|
696
|
+
}
|
|
697
|
+
} else {
|
|
698
|
+
// Legacy users: populate firebaseUids array (migration)
|
|
699
|
+
// Note: A single CID can have multiple emails/UIDs for different accounts
|
|
700
|
+
await verificationRef.update({
|
|
701
|
+
firebaseUids: FieldValue.arrayUnion(firebaseUid)
|
|
702
|
+
});
|
|
703
|
+
console.log(`[Migration] Added Firebase UID ${firebaseUid} to CID ${cid}`);
|
|
673
704
|
}
|
|
674
705
|
}
|
|
675
706
|
|
|
@@ -734,8 +765,7 @@ const requestPopularInvestorAddition = async (firestore, userId, piId, piUsernam
|
|
|
734
765
|
piUsername,
|
|
735
766
|
requestedBy: userId,
|
|
736
767
|
requestedAt: FieldValue.serverTimestamp(),
|
|
737
|
-
ttl:
|
|
738
|
-
|
|
768
|
+
ttl: Timestamp.fromDate(ttlDate)
|
|
739
769
|
});
|
|
740
770
|
const globalRequestRef = firestore.collection('PiAdditionRequestLogs').doc(todayStr).collection('requests').doc(piId);
|
|
741
771
|
await globalRequestRef.set({
|
|
@@ -933,14 +963,18 @@ const hasUserCopiedPopularInvestor = async (firestore, userId, popularInvestorId
|
|
|
933
963
|
// 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
964
|
// 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
965
|
const manageReviews = async (db, userId, action, params = {}) => {
|
|
966
|
+
// Sanitize user inputs
|
|
967
|
+
const sanitizedUserId = sanitizeCid(userId);
|
|
936
968
|
const { piId } = params;
|
|
969
|
+
const sanitizedPiId = sanitizeCid(piId);
|
|
970
|
+
|
|
937
971
|
const batch = db.batch();
|
|
938
972
|
const todayStr = new Date().toISOString().split('T')[0];
|
|
939
973
|
|
|
940
|
-
// Paths
|
|
941
|
-
const newReviewPath = db.collection('SignedInUsers').doc(
|
|
942
|
-
const piReviewPath = db.collection('PopularInvestors').doc(
|
|
943
|
-
const legacyReviewPath = db.collection('pi_reviews').doc(`${
|
|
974
|
+
// Paths - all user inputs sanitized
|
|
975
|
+
const newReviewPath = db.collection('SignedInUsers').doc(sanitizedUserId).collection('reviews').doc(sanitizedPiId);
|
|
976
|
+
const piReviewPath = db.collection('PopularInvestors').doc(sanitizedPiId).collection('reviews').doc(`${sanitizedUserId}_${sanitizedPiId}`);
|
|
977
|
+
const legacyReviewPath = db.collection('pi_reviews').doc(`${sanitizedUserId}_${sanitizedPiId}`);
|
|
944
978
|
const globalLogPath = db.collection('PiReviews').doc(todayStr).collection('shards').doc('daily_log');
|
|
945
979
|
|
|
946
980
|
try {
|
|
@@ -2048,9 +2082,87 @@ const searchPopularInvestors = async (db, queryStr) => {
|
|
|
2048
2082
|
// NEW: SYNC & RATE LIMITS (Rec 7, 9, 15)
|
|
2049
2083
|
// ==========================================
|
|
2050
2084
|
|
|
2051
|
-
const checkSyncRateLimits = async (db, targetId, requesterId, isDev) => {
|
|
2052
|
-
|
|
2085
|
+
const checkSyncRateLimits = async (db, targetId, requesterId, isDev, maxRequests = 1, windowMs = 6 * 60 * 60 * 1000) => {
|
|
2086
|
+
// Developers get higher limits (100 requests per hour globally, 10 per target) but still limited
|
|
2087
|
+
if (isDev) {
|
|
2088
|
+
const DEV_GLOBAL_LIMIT_MS = 60 * 60 * 1000; // 1 hour
|
|
2089
|
+
const DEV_GLOBAL_MAX_REQUESTS = 100; // Total requests per hour
|
|
2090
|
+
const DEV_PER_TARGET_LIMIT_MS = 60 * 60 * 1000; // 1 hour
|
|
2091
|
+
const DEV_PER_TARGET_MAX_REQUESTS = 10; // Requests per target per hour
|
|
2092
|
+
const now = Date.now();
|
|
2093
|
+
|
|
2094
|
+
// Check global developer limit (total requests across all targets)
|
|
2095
|
+
const globalDevRef = db.collection('developer_rate_limits').doc(String(requesterId)).collection('global').doc('latest');
|
|
2096
|
+
const globalDevSnap = await globalDevRef.get();
|
|
2097
|
+
|
|
2098
|
+
let globalRequestCount = 0;
|
|
2099
|
+
let globalWindowStart = now;
|
|
2100
|
+
|
|
2101
|
+
if (globalDevSnap.exists) {
|
|
2102
|
+
const globalData = globalDevSnap.data();
|
|
2103
|
+
globalRequestCount = globalData.requestCount || 0;
|
|
2104
|
+
globalWindowStart = globalData.windowStart?.toMillis() || now;
|
|
2105
|
+
|
|
2106
|
+
// Reset window if expired
|
|
2107
|
+
if (now - globalWindowStart >= DEV_GLOBAL_LIMIT_MS) {
|
|
2108
|
+
globalRequestCount = 0;
|
|
2109
|
+
globalWindowStart = now;
|
|
2110
|
+
}
|
|
2111
|
+
}
|
|
2112
|
+
|
|
2113
|
+
// Check per-target limit (prevent spamming specific users)
|
|
2114
|
+
const sanitizedTargetId = sanitizeCid(targetId);
|
|
2115
|
+
const perTargetRef = db.collection('user_sync_requests').doc(sanitizedTargetId).collection('developer_limits').doc(String(requesterId));
|
|
2116
|
+
const perTargetSnap = await perTargetRef.get();
|
|
2117
|
+
|
|
2118
|
+
let perTargetCount = 0;
|
|
2119
|
+
let perTargetWindowStart = now;
|
|
2120
|
+
|
|
2121
|
+
if (perTargetSnap.exists) {
|
|
2122
|
+
const perTargetData = perTargetSnap.data();
|
|
2123
|
+
perTargetCount = perTargetData.requestCount || 0;
|
|
2124
|
+
perTargetWindowStart = perTargetData.windowStart?.toMillis() || now;
|
|
2125
|
+
|
|
2126
|
+
// Reset window if expired
|
|
2127
|
+
if (now - perTargetWindowStart >= DEV_PER_TARGET_LIMIT_MS) {
|
|
2128
|
+
perTargetCount = 0;
|
|
2129
|
+
perTargetWindowStart = now;
|
|
2130
|
+
}
|
|
2131
|
+
|
|
2132
|
+
// Check per-target limit
|
|
2133
|
+
if (perTargetCount >= DEV_PER_TARGET_MAX_REQUESTS) {
|
|
2134
|
+
const waitMinutes = Math.ceil((perTargetWindowStart + DEV_PER_TARGET_LIMIT_MS - now) / 60000);
|
|
2135
|
+
return {
|
|
2136
|
+
allowed: false,
|
|
2137
|
+
message: `Developer rate limit exceeded for this target. Try again in ${waitMinutes} minutes.`
|
|
2138
|
+
};
|
|
2139
|
+
}
|
|
2140
|
+
}
|
|
2141
|
+
|
|
2142
|
+
// Check global limit
|
|
2143
|
+
if (globalRequestCount >= DEV_GLOBAL_MAX_REQUESTS) {
|
|
2144
|
+
const waitMinutes = Math.ceil((globalWindowStart + DEV_GLOBAL_LIMIT_MS - now) / 60000);
|
|
2145
|
+
return {
|
|
2146
|
+
allowed: false,
|
|
2147
|
+
message: `Developer global rate limit exceeded. Try again in ${waitMinutes} minutes.`
|
|
2148
|
+
};
|
|
2149
|
+
}
|
|
2150
|
+
|
|
2151
|
+
// Update both counters
|
|
2152
|
+
await globalDevRef.set({
|
|
2153
|
+
requestCount: globalRequestCount + 1,
|
|
2154
|
+
windowStart: Timestamp.fromMillis(globalWindowStart)
|
|
2155
|
+
}, { merge: true });
|
|
2156
|
+
|
|
2157
|
+
await perTargetRef.set({
|
|
2158
|
+
requestCount: perTargetCount + 1,
|
|
2159
|
+
windowStart: Timestamp.fromMillis(perTargetWindowStart)
|
|
2160
|
+
}, { merge: true });
|
|
2161
|
+
|
|
2162
|
+
return { allowed: true };
|
|
2163
|
+
}
|
|
2053
2164
|
|
|
2165
|
+
// Regular users: 1 request per 6 hours
|
|
2054
2166
|
const RATE_LIMIT_MS = 6 * 60 * 60 * 1000; // 6 hours
|
|
2055
2167
|
const now = Date.now();
|
|
2056
2168
|
|
|
@@ -2681,33 +2793,42 @@ const getWatchlistTriggerCounts = async (db, userId, watchlistId) => {
|
|
|
2681
2793
|
const piCid = String(item.cid);
|
|
2682
2794
|
let totalCount = 0;
|
|
2683
2795
|
|
|
2684
|
-
// Check each of the last 7 days
|
|
2685
|
-
|
|
2686
|
-
|
|
2687
|
-
|
|
2796
|
+
// Check each of the last 7 days - PARALLELIZED to fix N+1 query problem
|
|
2797
|
+
const dayDocs = await Promise.all(
|
|
2798
|
+
dates.map(dateStr =>
|
|
2799
|
+
db.collection('PIAlertHistoryData')
|
|
2688
2800
|
.doc(dateStr)
|
|
2689
|
-
.get()
|
|
2801
|
+
.get()
|
|
2802
|
+
.catch(e => {
|
|
2803
|
+
console.warn(`Error fetching date ${dateStr} for PI ${piCid}: ${e.message}`);
|
|
2804
|
+
return null; // Return null on error instead of throwing
|
|
2805
|
+
})
|
|
2806
|
+
)
|
|
2807
|
+
);
|
|
2808
|
+
|
|
2809
|
+
// Process all day documents
|
|
2810
|
+
for (const dayDoc of dayDocs) {
|
|
2811
|
+
if (!dayDoc || !dayDoc.exists) continue;
|
|
2812
|
+
|
|
2813
|
+
try {
|
|
2814
|
+
const dayData = dayDoc.data();
|
|
2815
|
+
const piData = dayData[piCid];
|
|
2690
2816
|
|
|
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
|
-
}
|
|
2817
|
+
if (piData) {
|
|
2818
|
+
// Check all alert types for this PI
|
|
2819
|
+
for (const [alertType, alertData] of Object.entries(piData)) {
|
|
2820
|
+
if (alertType === 'lastUpdated' || typeof alertData !== 'object') continue;
|
|
2821
|
+
|
|
2822
|
+
// Check if triggeredFor array contains this user
|
|
2823
|
+
const triggeredFor = alertData.triggeredFor || [];
|
|
2824
|
+
if (Array.isArray(triggeredFor) && triggeredFor.includes(userCidStr)) {
|
|
2825
|
+
totalCount += alertData.count || 1; // Count each trigger
|
|
2705
2826
|
}
|
|
2706
2827
|
}
|
|
2707
2828
|
}
|
|
2708
2829
|
} catch (e) {
|
|
2709
|
-
// Skip this day if error
|
|
2710
|
-
console.warn(`Error
|
|
2830
|
+
// Skip this day if error processing
|
|
2831
|
+
console.warn(`Error processing day data for PI ${piCid}: ${e.message}`);
|
|
2711
2832
|
}
|
|
2712
2833
|
}
|
|
2713
2834
|
|
|
@@ -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
|
|
|
@@ -28,8 +29,10 @@ function createApiV2App(config, dependencies) {
|
|
|
28
29
|
const { logger } = dependencies;
|
|
29
30
|
|
|
30
31
|
// Trust proxy - Required when behind a load balancer/proxy (e.g., Google Cloud Functions)
|
|
32
|
+
// Trust only the first proxy (the load balancer) to prevent IP spoofing
|
|
31
33
|
// This allows express-rate-limit to correctly identify client IPs from X-Forwarded-For headers
|
|
32
|
-
|
|
34
|
+
// Setting to 1 means we trust only the first proxy, not all proxies (which would be insecure)
|
|
35
|
+
app.set('trust proxy', 1);
|
|
33
36
|
|
|
34
37
|
// CORS Configuration - Restrict to specific origins
|
|
35
38
|
app.use(cors({
|
|
@@ -70,6 +73,13 @@ function createApiV2App(config, dependencies) {
|
|
|
70
73
|
app.use('/verification', authLimiter);
|
|
71
74
|
app.use('/', apiLimiter);
|
|
72
75
|
|
|
76
|
+
// Request ID tracking middleware (for debugging and error tracking)
|
|
77
|
+
app.use((req, res, next) => {
|
|
78
|
+
req.requestId = uuidv4();
|
|
79
|
+
res.setHeader('X-Request-ID', req.requestId);
|
|
80
|
+
next();
|
|
81
|
+
});
|
|
82
|
+
|
|
73
83
|
app.use(express.json());
|
|
74
84
|
|
|
75
85
|
// Health Check
|