Skip to content

Commit b2c89ef

Browse files
committed
feat: Enhance User Progression API with input validation, rate limiting, and data sanitization
- Implemented username and userId validation to prevent injection attacks and ensure correct formats. - Added rate limiting for public and authenticated requests to improve API security. - Introduced a sanitization function to remove sensitive fields from the progression data before returning it. - Enhanced error handling to prevent user enumeration and expose minimal internal error details.
1 parent 3c2122b commit b2c89ef

File tree

1 file changed

+118
-12
lines changed

1 file changed

+118
-12
lines changed

api/userProgression.js

Lines changed: 118 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,114 @@
1+
import mongoose from 'mongoose';
12
import User from '../models/User.js';
23
import UserStatsService from '../components/utils/userStatsService.js';
4+
import { rateLimit } from '../utils/rateLimit.js';
35

6+
// Username validation regex: alphanumeric and underscores only, 3-20 characters
7+
const USERNAME_REGEX = /^[a-zA-Z0-9_]{3,20}$/;
8+
9+
// MongoDB ObjectId validation regex
10+
const OBJECT_ID_REGEX = /^[0-9a-fA-F]{24}$/;
11+
12+
/**
13+
* Sanitize progression data by removing sensitive fields
14+
* @param {Array} progression - Raw progression data
15+
* @param {boolean} isPublic - Whether this is a public (username-based) request
16+
* @returns {Array} Sanitized progression data
17+
*/
18+
function sanitizeProgression(progression, isPublic = false) {
19+
return progression.map(stat => {
20+
const sanitized = {
21+
timestamp: stat.timestamp,
22+
totalXp: stat.totalXp,
23+
xpRank: stat.xpRank,
24+
elo: stat.elo,
25+
eloRank: stat.eloRank,
26+
triggerEvent: stat.triggerEvent,
27+
// Calculated fields
28+
xpGain: stat.xpGain || 0,
29+
eloChange: stat.eloChange || 0,
30+
rankImprovement: stat.rankImprovement || 0
31+
};
32+
33+
// Never expose userId for public requests
34+
if (!isPublic) {
35+
sanitized.userId = stat.userId;
36+
}
37+
38+
// Never expose gameId, eloRefundDetails, or other sensitive fields
39+
// These are intentionally excluded for security
40+
41+
return sanitized;
42+
});
43+
}
44+
45+
/**
46+
* User Progression API Endpoint
47+
* Returns user stats progression for charts
48+
* Includes rate limiting, input validation, and security measures
49+
*/
450
export default async function handler(req, res) {
551
// Only allow POST requests
652
if (req.method !== 'POST') {
753
return res.status(405).json({ message: 'Method not allowed' });
854
}
955

10-
try {
11-
const { userId, username } = req.body;
56+
// Determine if this is a public (username-based) or authenticated (userId-based) request
57+
const { userId, username } = req.body;
58+
const isPublicRequest = !userId && !!username;
59+
60+
// Apply stricter rate limiting for public requests
61+
// Public: 5 requests per minute per IP
62+
// Authenticated: 20 requests per minute per IP
63+
const limiter = rateLimit({
64+
max: isPublicRequest ? 5 : 20,
65+
windowMs: 60000,
66+
message: 'Too many requests. Please try again later.'
67+
});
68+
69+
if (!limiter(req, res)) {
70+
return; // Rate limit exceeded, response already sent
71+
}
1272

13-
// Accept either userId or username
73+
try {
74+
// Validate input: must provide either userId or username, but not both
1475
if (!userId && !username) {
1576
return res.status(400).json({ message: 'UserId or username is required' });
1677
}
1778

79+
if (userId && username) {
80+
return res.status(400).json({ message: 'Provide either userId or username, not both' });
81+
}
82+
83+
// Validate userId format (MongoDB ObjectId)
84+
if (userId) {
85+
if (typeof userId !== 'string' || !OBJECT_ID_REGEX.test(userId)) {
86+
return res.status(400).json({ message: 'Invalid userId format' });
87+
}
88+
}
89+
90+
// Validate username format (prevent injection attacks)
91+
if (username) {
92+
if (typeof username !== 'string') {
93+
return res.status(400).json({ message: 'Username must be a string' });
94+
}
95+
if (!USERNAME_REGEX.test(username)) {
96+
return res.status(400).json({
97+
message: 'Invalid username format. Username must be 3-20 characters and contain only letters, numbers, and underscores.'
98+
});
99+
}
100+
}
101+
102+
// Connect to MongoDB if not already connected
103+
if (mongoose.connection.readyState !== 1) {
104+
try {
105+
await mongoose.connect(process.env.MONGODB);
106+
} catch (error) {
107+
console.error('Database connection failed:', error);
108+
return res.status(500).json({ message: 'Internal server error' });
109+
}
110+
}
111+
18112
// Find user by userId or username
19113
let user;
20114
if (userId) {
@@ -23,30 +117,42 @@ export default async function handler(req, res) {
23117
user = await User.findOne({ username: username });
24118
}
25119

120+
// Generic error message to prevent user enumeration
26121
if (!user) {
27122
return res.status(404).json({ message: 'User not found' });
28123
}
29124

30125
// Exclude banned users and users with pending name changes (public API security)
31-
if ((user.banned === true || user.pendingNameChange === true) && !userId) {
126+
if ((user.banned === true || user.pendingNameChange === true) && isPublicRequest) {
32127
// Only apply this check for username-based requests (public access)
33128
// Allow userId-based requests (authenticated user viewing their own data)
34129
return res.status(404).json({ message: 'User not found' });
35130
}
36131

37-
// Get user's stats progression - all available data
132+
// Get user's stats progression
38133
const progression = await UserStatsService.getUserProgression(user._id);
39134

40-
return res.status(200).json({
41-
progression,
42-
userId: user._id,
135+
// Sanitize progression data - remove gameId and other sensitive fields
136+
const sanitizedProgression = sanitizeProgression(progression, isPublicRequest);
137+
138+
// Build response
139+
const response = {
140+
progression: sanitizedProgression,
43141
username: user.username
44-
});
142+
};
143+
144+
// Only include userId for authenticated requests (not public)
145+
if (!isPublicRequest) {
146+
response.userId = user._id.toString();
147+
}
148+
149+
return res.status(200).json(response);
150+
45151
} catch (error) {
46152
console.error('Error fetching user progression:', error);
153+
// Don't expose internal error details in production
47154
return res.status(500).json({
48-
message: 'An error occurred',
49-
error: error.message
155+
message: 'An error occurred while fetching progression data'
50156
});
51157
}
52-
}
158+
}

0 commit comments

Comments
 (0)