diff --git a/app/api/track-user/route.test.ts b/app/api/track-user/route.test.ts index 51d4a7c79..ecd0493fd 100644 --- a/app/api/track-user/route.test.ts +++ b/app/api/track-user/route.test.ts @@ -155,6 +155,25 @@ describe('POST /api/track-user', () => { const data = await response.json(); expect(data.success).toBe(false); }); + + it('sanitizes and rejects nested MongoDB operators in username field', async () => { + const response = await POST(makeRequest({ username: { $ne: 'octocat' } })); + expect(response.status).toBe(400); + const data = await response.json(); + expect(data.success).toBe(false); + expect(data.error).toBe('Invalid or missing username'); + }); + + it('sanitizes query injection fields from root payload', async () => { + process.env.MONGODB_URI = 'mongodb://localhost:27017/test'; + const response = await POST(makeRequest({ username: 'valid-user', $where: 'javascript' })); + expect(response.status).toBe(200); + const data = await response.json(); + expect(data.success).toBe(true); + expect(User.updateOne).toHaveBeenCalledWith({ username: 'valid-user' }, expect.any(Object), { + upsert: true, + }); + }); }); it('returns 429 with rate limit headers when rate limited', async () => { diff --git a/app/api/track-user/route.ts b/app/api/track-user/route.ts index d3c84dae6..1dad21e0e 100644 --- a/app/api/track-user/route.ts +++ b/app/api/track-user/route.ts @@ -5,6 +5,7 @@ import { getClientIp } from '@/utils/getClientIp'; import { getRateLimitHeaders, trackUserRateLimiter } from '@/lib/rate-limit'; import { trackUserProtection } from '@/services/security/track-user-protection'; import { githubUsernameSchema } from '@/lib/validations'; +import { sanitizeMongoPayload } from '@/utils/sanitize'; export async function POST(req: Request) { // Get IP for rate limiting securely @@ -32,6 +33,9 @@ export async function POST(req: Request) { ); } + // Sanitize MongoDB operators from body to prevent injection + sanitizeMongoPayload(body); + try { const { username } = body as { username?: unknown }; diff --git a/utils/sanitize.test.ts b/utils/sanitize.test.ts new file mode 100644 index 000000000..87f82438e --- /dev/null +++ b/utils/sanitize.test.ts @@ -0,0 +1,56 @@ +import { describe, it, expect } from 'vitest'; +import { sanitizeMongoPayload } from './sanitize'; + +describe('sanitizeMongoPayload', () => { + it('should not modify normal objects', () => { + const input = { username: 'octocat', age: 10 }; + const result = sanitizeMongoPayload(input); + expect(result).toEqual({ username: 'octocat', age: 10 }); + }); + + it('should remove keys starting with $ at the root level', () => { + const input = { username: 'octocat', $where: 'javascript' }; + const result = sanitizeMongoPayload(input); + expect(result).toEqual({ username: 'octocat' }); + }); + + it('should recursively remove keys starting with $ from nested objects', () => { + const input = { + username: { $ne: 'octocat' }, + profile: { + name: 'Octo', + details: { + $gt: 5, + validKey: 'hello', + }, + }, + }; + const result = sanitizeMongoPayload(input); + expect(result).toEqual({ + username: {}, + profile: { + name: 'Octo', + details: { + validKey: 'hello', + }, + }, + }); + }); + + it('should recursively remove keys starting with $ from arrays of objects', () => { + const input = [ + { username: 'octocat' }, + { $gt: 'malicious' }, + { details: [{ $eq: 1 }, { val: 2 }] }, + ]; + const result = sanitizeMongoPayload(input); + expect(result).toEqual([{ username: 'octocat' }, {}, { details: [{}, { val: 2 }] }]); + }); + + it('should handle primitives and null values safely', () => { + expect(sanitizeMongoPayload(null)).toBeNull(); + expect(sanitizeMongoPayload(undefined)).toBeUndefined(); + expect(sanitizeMongoPayload('string')).toBe('string'); + expect(sanitizeMongoPayload(123)).toBe(123); + }); +}); diff --git a/utils/sanitize.ts b/utils/sanitize.ts new file mode 100644 index 000000000..b858ff6e7 --- /dev/null +++ b/utils/sanitize.ts @@ -0,0 +1,27 @@ +/** + * Recursively scans and deletes keys starting with $ from input objects. + * Prevents MongoDB query operator injection by stripping out MongoDB operators. + */ +export function sanitizeMongoPayload(input: T): T { + if (input === null || typeof input !== 'object') { + return input; + } + + if (Array.isArray(input)) { + for (let i = 0; i < input.length; i++) { + input[i] = sanitizeMongoPayload(input[i]); + } + return input; + } + + const obj = input as Record; + for (const key of Object.keys(obj)) { + if (key.startsWith('$')) { + delete obj[key]; + } else { + obj[key] = sanitizeMongoPayload(obj[key]); + } + } + + return input; +}