| name | preference-cooldown-bypass-bug |
| description | Fix for user preference rate limiting bypass vulnerability. Use when: (1) Users can reset
rate limits by toggling preference settings, (2) Cooldown tracking uses preference.updatedAt
field, (3) Email/notification spam prevention fails after preference changes, (4) Rate limit
state stored in same record as enabled/disabled preference. Covers messaging cooldowns,
notification throttling, and any preference-based rate limiting system.
|
| author | Claude Code |
| version | 1.0.0 |
| date | "2026-01-23T00:00:00.000Z" |
Preference Cooldown Bypass Bug
Problem
Using a preference record's updatedAt timestamp for rate limiting allows users to bypass
cooldowns by toggling the preference on/off. This creates a security vulnerability where spam
prevention can be circumvented.
Context / Trigger Conditions
- Rate limiting implemented using preference
updatedAt field
- Users can toggle preferences (enable/disable) that also track cooldown state
- Cooldown resets unexpectedly when user changes preference settings
- Email or notification spam occurs despite cooldown being in place
- Code pattern:
if (preference.updatedAt > cooldownThreshold) for rate checks
Example vulnerable code:
const preference = await db.userPreference.findFirst({
where: { userId, key: "email.notification.messaging" }
});
if (preference.value !== "true") {
return { send: false };
}
const lastSent = preference.updatedAt;
const cooldownExpiry = new Date(lastSent.getTime() + COOLDOWN_MS);
if (new Date() < cooldownExpiry) {
return { send: false };
}
Solution
Separate Concerns: Use dedicated preference records for cooldown tracking, independent
from enabled/disabled state preferences.
Step 1: Add Dedicated Cooldown Preference Key
export const EMAIL_PREFERENCE_KEYS = {
MESSAGING: "email.notification.messaging",
MESSAGE_RECEIVED_COOLDOWN: "email.notification.messaging.lastSent",
} as const;
Step 2: Query Both Preferences Separately
async function shouldSendEmail(userId: string) {
const preference = await db.userPreference.findFirst({
where: { userId, key: EMAIL_PREFERENCE_KEYS.MESSAGING }
});
if (preference?.value !== "true") {
return { send: false, reason: "Notifications disabled" };
}
const cooldownPreference = await db.userPreference.findUnique({
where: {
userId_key: {
userId,
key: EMAIL_PREFERENCE_KEYS.MESSAGE_RECEIVED_COOLDOWN,
},
},
});
if (cooldownPreference) {
const lastSent = new Date(cooldownPreference.value);
if (!isNaN(lastSent.getTime())) {
const cooldownExpiry = new Date(lastSent.getTime() + COOLDOWN_MS);
if (new Date() < cooldownExpiry) {
const minutesRemaining = Math.ceil(
(cooldownExpiry.getTime() - Date.now()) / (60 * 1000)
);
return {
send: false,
reason: `Cooldown active (${minutesRemaining} minutes remaining)`,
};
}
}
}
return { send: true };
}
Step 3: Update Cooldown After Successful Action
async function updateCooldown(userId: string): Promise<void> {
try {
const now = new Date();
await db.userPreference.upsert({
where: {
userId_key: {
userId,
key: EMAIL_PREFERENCE_KEYS.MESSAGE_RECEIVED_COOLDOWN,
},
},
create: {
userId,
key: EMAIL_PREFERENCE_KEYS.MESSAGE_RECEIVED_COOLDOWN,
value: now.toISOString(),
category: PreferenceCategory.EMAIL_NOTIFICATION,
},
update: {
value: now.toISOString(),
updatedAt: now,
},
});
} catch (error) {
console.error("Error updating cooldown:", error);
}
}
Step 4: Update Tests to Mock Both Queries
it("should skip when within cooldown window", async () => {
const thirtyMinutesAgo = new Date(Date.now() - 30 * 60 * 1000);
mockDb.userPreference.findFirst = vi.fn().mockImplementation(({ where }) => {
if (where.key === EMAIL_PREFERENCE_KEYS.MESSAGING) {
return Promise.resolve({
userId,
key: EMAIL_PREFERENCE_KEYS.MESSAGING,
value: "true",
});
}
return Promise.resolve(null);
});
mockDb.userPreference.findUnique = vi.fn().mockImplementation(({ where }) => {
if (where.userId_key?.key === EMAIL_PREFERENCE_KEYS.MESSAGE_RECEIVED_COOLDOWN) {
return Promise.resolve({
userId,
key: EMAIL_PREFERENCE_KEYS.MESSAGE_RECEIVED_COOLDOWN,
value: thirtyMinutesAgo.toISOString(),
});
}
return Promise.resolve(null);
});
const result = await sendWithCooldown();
expect(result.skipped).toBe(true);
expect(result.reason).toContain("cooldown");
});
Verification
-
Test Bypass Scenario:
await sendEmail();
await updatePreference(userId, "email.notification.messaging", "false");
await updatePreference(userId, "email.notification.messaging", "true");
const result = await sendEmail();
expect(result.skipped).toBe(true);
expect(result.reason).toContain("cooldown");
-
Verify Separation: Check database - toggling enabled preference should NOT update
the cooldown preference record.
-
Test Cooldown Expiry: After cooldown period (e.g., 1 hour), email should send even
if preference was toggled during cooldown.
Example
Real-World Scenario: Messaging notification system with 1-hour cooldown to prevent spam.
Before (Vulnerable):
After (Fixed):
Notes
- Storage Pattern: Store ISO timestamp in
value field, not updatedAt field
- Query Pattern: Use
findUnique with composite key for cooldown checks (more efficient)
- Error Handling: Validate timestamp with
!isNaN(date.getTime()) before using
- Non-Fatal Updates: Cooldown update failures should log errors but not block main operation
- Database Design: Consider adding index on
userId_key composite for performance
- Migration Path: Existing systems need data migration to separate cooldown records
References