Skip to content
Open
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
15 changes: 9 additions & 6 deletions app/api/track-user/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,8 @@ export async function POST(req: Request) {
// Sanitize MongoDB operators from body to prevent injection
sanitizeMongoPayload(body);

const trimmedUsername = '';

try {
const { username } = body as { username?: unknown };

Expand All @@ -93,7 +95,7 @@ export async function POST(req: Request) {
const trimmedUsername = username.trim().toLowerCase();

// Coordinate security validations and deduplication checks
const validation = await trackUserProtection.verifyAndDeduplicate(trimmedUsername);
const validation = await trackUserProtection.tryReserve(trimmedUsername);
if (!validation.allowed) {
if (validation.reason === 'COOLDOWN_ACTIVE') {
// Return 200 OK with duplicate track indicator to bypass write and keep response fast
Expand All @@ -116,6 +118,7 @@ export async function POST(req: Request) {
logger.error('User tracking disabled: MONGODB_URI is not set', {
environment: process.env.NODE_ENV,
});
trackUserProtection.releaseReservation(trimmedUsername);
return NextResponse.json(
{ success: false, error: 'Database configuration error' },
{ status: 500 }
Expand All @@ -126,7 +129,6 @@ export async function POST(req: Request) {
logger.warn('User tracking bypassed: MONGODB_URI is not set', {
environment: process.env.NODE_ENV,
});
trackUserProtection.recordWrite(trimmedUsername);
return NextResponse.json({ success: true, bypassed: true });
}

Expand Down Expand Up @@ -161,9 +163,6 @@ export async function POST(req: Request) {
if (!result.success) {
throw result.error;
}

// Record successful database write
trackUserProtection.recordWrite(trimmedUsername);
} catch (upsertError) {
// Gracefully handle MongoDB E11000 duplicate key race conditions under high concurrency.
if (
Expand All @@ -179,17 +178,21 @@ export async function POST(req: Request) {
(typeof err.message === 'string' && err.message.includes('username'));

if (isUsernameConflict) {
trackUserProtection.recordWrite(trimmedUsername);
return NextResponse.json({ success: true });
}
}

console.warn('Database operation failed or timed out. Bypassing user tracking:', upsertError);
trackUserProtection.releaseReservation(trimmedUsername);
return NextResponse.json({ success: true });
}

return NextResponse.json({ success: true });
} catch (error) {
if (trimmedUsername) {
trackUserProtection.releaseReservation(trimmedUsername);
}

logger.error('Failed to track user', {
route: '/api/track-user',
error,
Expand Down
41 changes: 41 additions & 0 deletions services/security/track-user-protection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,47 @@ export class TrackUserProtection {
this.lastWriteTimes.set(sanitized, Date.now(), WRITE_COOLDOWN_MS);
}

/**
* Releases a reserved slot if GitHub validation or DB operations fail downstream.
*/
public releaseReservation(username: string): void {
const sanitized = username.trim().toLowerCase();
this.lastWriteTimes.delete(sanitized);
}

/**
* Atomically checks cooldown and reserves the slot immediately to prevent TOCTOU race conditions.
* Verifies format, reserves the cooldown window, and checks user existence.
*/
public async tryReserve(username: string): Promise<{
allowed: boolean;
reason?: 'INVALID_FORMAT' | 'COOLDOWN_ACTIVE' | 'USER_NOT_FOUND';
remainingMs?: number;
}> {
if (!this.validateFormat(username)) {
return { allowed: false, reason: 'INVALID_FORMAT' };
}

const sanitized = username.trim().toLowerCase();

if (!this.isWriteAllowed(sanitized)) {
const lastWrite = this.lastWriteTimes.get(sanitized) || 0;
const remainingMs = Math.max(0, WRITE_COOLDOWN_MS - (Date.now() - lastWrite));
return { allowed: false, reason: 'COOLDOWN_ACTIVE', remainingMs };
}

// Reserve slot immediately before external async checks
this.recordWrite(sanitized);

const exists = await gitHubUserValidator.validateUser(username);
if (!exists) {
this.releaseReservation(sanitized);
return { allowed: false, reason: 'USER_NOT_FOUND' };
}

return { allowed: true };
}

/**
* Coordinates both format, cooldown, and existence checks.
*/
Expand Down
Loading