Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions app/api/track-user/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
4 changes: 4 additions & 0 deletions app/api/track-user/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 };

Expand Down
56 changes: 56 additions & 0 deletions utils/sanitize.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
27 changes: 27 additions & 0 deletions utils/sanitize.ts
Original file line number Diff line number Diff line change
@@ -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<T>(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<string, unknown>;
for (const key of Object.keys(obj)) {
if (key.startsWith('$')) {
delete obj[key];
} else {
obj[key] = sanitizeMongoPayload(obj[key]);
}
}

return input;
}
Loading