@tuturuuu/utils 0.7.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -34,6 +34,18 @@ export * from './user-agent';
34
34
  // In-memory fallback store
35
35
  const memoryStore = new Map<string, { count: number; expiresAt: number }>();
36
36
 
37
+ const SHARED_IP_THROTTLE_ONLY_BLOCK_REASONS = new Set<AbuseEventType>([
38
+ 'otp_send',
39
+ 'otp_verify_failed',
40
+ 'mfa_verify_failed',
41
+ 'reauth_verify_failed',
42
+ 'password_login_failed',
43
+ ]);
44
+
45
+ export function isSharedIpThrottleOnlyBlockReason(reason: AbuseEventType) {
46
+ return SHARED_IP_THROTTLE_ONLY_BLOCK_REASONS.has(reason);
47
+ }
48
+
37
49
  // Redis client singleton (lazy initialized)
38
50
  let redisClient: RedisClient | null = null;
39
51
  let redisInitialized = false;
@@ -239,44 +251,6 @@ async function getCounter(key: string): Promise<number> {
239
251
  return existing.count;
240
252
  }
241
253
 
242
- async function getCounterWithTTL(
243
- key: string
244
- ): Promise<{ count: number; ttl: number }> {
245
- const redis = await getRedisClient();
246
-
247
- if (redis) {
248
- try {
249
- const [count, ttl] = await Promise.all([
250
- redis.get<number>(key),
251
- redis.ttl(key),
252
- ]);
253
-
254
- return {
255
- count: count || 0,
256
- ttl: ttl > 0 ? ttl : 0,
257
- };
258
- } catch {
259
- // Fall through to memory
260
- }
261
- }
262
-
263
- const existing = memoryStore.get(key);
264
- if (!existing) {
265
- return { count: 0, ttl: 0 };
266
- }
267
-
268
- const now = Date.now();
269
- if (now > existing.expiresAt) {
270
- memoryStore.delete(key);
271
- return { count: 0, ttl: 0 };
272
- }
273
-
274
- return {
275
- count: existing.count,
276
- ttl: Math.ceil((existing.expiresAt - now) / 1000),
277
- };
278
- }
279
-
280
254
  /**
281
255
  * Delete keys from Redis or memory
282
256
  */
@@ -434,6 +408,10 @@ export async function blockIP(
434
408
  reason: AbuseEventType,
435
409
  metadata?: Record<string, unknown>
436
410
  ): Promise<void> {
411
+ if (isSharedIpThrottleOnlyBlockReason(reason)) {
412
+ return;
413
+ }
414
+
437
415
  try {
438
416
  const sbAdmin = await getSupabaseAdmin();
439
417
  if (!sbAdmin) return;
@@ -902,7 +880,10 @@ export async function resetOtpLimitsForEmail({
902
880
  }
903
881
 
904
882
  /**
905
- * Check and track OTP send attempts
883
+ * Check and reserve OTP send attempts.
884
+ *
885
+ * Accepted sends consume quota before provider delivery to prevent parallel
886
+ * requests from all observing the same pre-send counter state.
906
887
  */
907
888
  export async function checkOTPSendAllowed(
908
889
  ipAddress: string,
@@ -928,25 +909,22 @@ export async function checkOTPSendAllowed(
928
909
  const dailyKey = REDIS_KEYS.OTP_SEND_DAILY(ipAddress);
929
910
 
930
911
  const [minuteState, hourlyState, dailyState] = await Promise.all([
931
- getCounterWithTTL(minuteKey),
932
- getCounterWithTTL(hourlyKey),
933
- getCounterWithTTL(dailyKey),
912
+ incrementCounter(minuteKey, WINDOW_MS.ONE_MINUTE),
913
+ incrementCounter(hourlyKey, WINDOW_MS.ONE_HOUR),
914
+ incrementCounter(dailyKey, WINDOW_MS.TWENTY_FOUR_HOURS),
934
915
  ]);
935
916
  const ipLimits = getOTPSendIpLimits();
936
917
 
937
- if (minuteState.count >= ipLimits.perMinute) {
938
- // Log and potentially block
918
+ if (minuteState.count > ipLimits.perMinute) {
939
919
  void logAbuseEvent(ipAddress, 'otp_send', {
940
920
  email,
941
921
  success: false,
942
- metadata: { trigger: 'minute_limit' },
922
+ metadata: {
923
+ trigger: 'minute_limit',
924
+ hard_block_suppressed: minuteState.count >= ipLimits.perMinute * 2,
925
+ },
943
926
  });
944
927
 
945
- if (minuteState.count >= ipLimits.perMinute * 2) {
946
- // Aggressive abuse - block IP
947
- void blockIP(ipAddress, 'otp_send', { trigger: 'rate_limit_exceeded' });
948
- }
949
-
950
928
  return {
951
929
  allowed: false,
952
930
  reason: 'Too many OTP requests. Please try again later.',
@@ -955,15 +933,16 @@ export async function checkOTPSendAllowed(
955
933
  };
956
934
  }
957
935
 
958
- if (hourlyState.count >= ipLimits.perHour) {
936
+ if (hourlyState.count > ipLimits.perHour) {
959
937
  void logAbuseEvent(ipAddress, 'otp_send', {
960
938
  email,
961
939
  success: false,
962
- metadata: { trigger: 'hourly_rate_limit' },
940
+ metadata: {
941
+ trigger: 'hourly_rate_limit',
942
+ hard_block_suppressed: true,
943
+ },
963
944
  });
964
945
 
965
- void blockIP(ipAddress, 'otp_send', { trigger: 'hourly_rate_limit' });
966
-
967
946
  return {
968
947
  allowed: false,
969
948
  reason: 'Hourly OTP limit reached. Please try again later.',
@@ -972,13 +951,15 @@ export async function checkOTPSendAllowed(
972
951
  };
973
952
  }
974
953
 
975
- if (dailyState.count >= ipLimits.perDay) {
954
+ if (dailyState.count > ipLimits.perDay) {
976
955
  void logAbuseEvent(ipAddress, 'otp_send', {
977
956
  email,
978
957
  success: false,
979
- metadata: { trigger: 'ip_daily_limit' },
958
+ metadata: {
959
+ trigger: 'ip_daily_limit',
960
+ hard_block_suppressed: true,
961
+ },
980
962
  });
981
- void blockIP(ipAddress, 'otp_send', { trigger: 'ip_daily_limit' });
982
963
 
983
964
  return {
984
965
  allowed: false,
@@ -995,14 +976,11 @@ export async function checkOTPSendAllowed(
995
976
  const hourlyEmailKey = REDIS_KEYS.OTP_SEND_EMAIL_HOURLY(emailHash);
996
977
  const dailyEmailKey = REDIS_KEYS.OTP_SEND_EMAIL_DAILY(emailHash);
997
978
 
998
- const [cooldownState, hourlyEmailState, dailyEmailState] =
999
- await Promise.all([
1000
- getCounterWithTTL(cooldownKey),
1001
- getCounterWithTTL(hourlyEmailKey),
1002
- getCounterWithTTL(dailyEmailKey),
1003
- ]);
1004
-
1005
- if (cooldownState.count >= 1) {
979
+ const cooldownState = await incrementCounter(
980
+ cooldownKey,
981
+ ABUSE_THRESHOLDS.OTP_SEND_EMAIL_COOLDOWN_WINDOW_MS
982
+ );
983
+ if (cooldownState.count > 1) {
1006
984
  void logAbuseEvent(ipAddress, 'otp_send', {
1007
985
  email,
1008
986
  success: false,
@@ -1017,7 +995,12 @@ export async function checkOTPSendAllowed(
1017
995
  };
1018
996
  }
1019
997
 
1020
- if (hourlyEmailState.count >= ABUSE_THRESHOLDS.OTP_SEND_EMAIL_PER_HOUR) {
998
+ const [hourlyEmailState, dailyEmailState] = await Promise.all([
999
+ incrementCounter(hourlyEmailKey, WINDOW_MS.ONE_HOUR),
1000
+ incrementCounter(dailyEmailKey, WINDOW_MS.TWENTY_FOUR_HOURS),
1001
+ ]);
1002
+
1003
+ if (hourlyEmailState.count > ABUSE_THRESHOLDS.OTP_SEND_EMAIL_PER_HOUR) {
1021
1004
  void logAbuseEvent(ipAddress, 'otp_send', {
1022
1005
  email,
1023
1006
  success: false,
@@ -1032,7 +1015,7 @@ export async function checkOTPSendAllowed(
1032
1015
  };
1033
1016
  }
1034
1017
 
1035
- if (dailyEmailState.count >= ABUSE_THRESHOLDS.OTP_SEND_EMAIL_PER_DAY) {
1018
+ if (dailyEmailState.count > ABUSE_THRESHOLDS.OTP_SEND_EMAIL_PER_DAY) {
1036
1019
  void logAbuseEvent(ipAddress, 'otp_send', {
1037
1020
  email,
1038
1021
  success: false,
@@ -1050,10 +1033,7 @@ export async function checkOTPSendAllowed(
1050
1033
 
1051
1034
  return {
1052
1035
  allowed: true,
1053
- remainingAttempts: Math.max(
1054
- 0,
1055
- ipLimits.perMinute - (minuteState.count + 1)
1056
- ),
1036
+ remainingAttempts: Math.max(0, ipLimits.perMinute - minuteState.count),
1057
1037
  };
1058
1038
  }
1059
1039
 
@@ -1061,31 +1041,6 @@ export async function recordOTPSendSuccess(
1061
1041
  ipAddress: string,
1062
1042
  email?: string
1063
1043
  ): Promise<void> {
1064
- await Promise.all([
1065
- incrementCounter(REDIS_KEYS.OTP_SEND(ipAddress), WINDOW_MS.ONE_MINUTE),
1066
- incrementCounter(REDIS_KEYS.OTP_SEND_HOURLY(ipAddress), WINDOW_MS.ONE_HOUR),
1067
- incrementCounter(
1068
- REDIS_KEYS.OTP_SEND_DAILY(ipAddress),
1069
- WINDOW_MS.TWENTY_FOUR_HOURS
1070
- ),
1071
- ...(email
1072
- ? [
1073
- incrementCounter(
1074
- REDIS_KEYS.OTP_SEND_EMAIL_COOLDOWN(hashEmail(email)),
1075
- ABUSE_THRESHOLDS.OTP_SEND_EMAIL_COOLDOWN_WINDOW_MS
1076
- ),
1077
- incrementCounter(
1078
- REDIS_KEYS.OTP_SEND_EMAIL_HOURLY(hashEmail(email)),
1079
- WINDOW_MS.ONE_HOUR
1080
- ),
1081
- incrementCounter(
1082
- REDIS_KEYS.OTP_SEND_EMAIL_DAILY(hashEmail(email)),
1083
- WINDOW_MS.TWENTY_FOUR_HOURS
1084
- ),
1085
- ]
1086
- : []),
1087
- ]);
1088
-
1089
1044
  void logAbuseEvent(ipAddress, 'otp_send', { email, success: true });
1090
1045
  }
1091
1046
 
@@ -1175,16 +1130,35 @@ export async function recordOTPVerifyFailure(
1175
1130
  ),
1176
1131
  ]);
1177
1132
 
1178
- // Log the failure
1179
- void logAbuseEvent(ipAddress, 'otp_verify_failed', { email, success: false });
1133
+ void logAbuseEvent(ipAddress, 'otp_verify_failed', {
1134
+ email,
1135
+ success: false,
1136
+ metadata: createHumanAuthRateLimitMetadata(
1137
+ ipCount,
1138
+ ABUSE_THRESHOLDS.OTP_VERIFY_FAILED_MAX
1139
+ ),
1140
+ });
1141
+ }
1180
1142
 
1181
- // Block if threshold exceeded
1182
- if (ipCount >= ABUSE_THRESHOLDS.OTP_VERIFY_FAILED_MAX) {
1183
- void blockIP(ipAddress, 'otp_verify_failed', {
1184
- trigger: 'max_failures_exceeded',
1185
- failedCount: ipCount,
1186
- });
1143
+ /**
1144
+ * Shared NATs can represent a whole classroom, university, or enterprise.
1145
+ * Human-auth counters still throttle the noisy flow, but they must not write a
1146
+ * global IP block that prevents unrelated people on the same public address
1147
+ * from signing in.
1148
+ */
1149
+ function createHumanAuthRateLimitMetadata(
1150
+ failedCount: number,
1151
+ maxFailures: number
1152
+ ): Record<string, unknown> | undefined {
1153
+ if (failedCount < maxFailures) {
1154
+ return undefined;
1187
1155
  }
1156
+
1157
+ return {
1158
+ trigger: 'max_failures_exceeded',
1159
+ failedCount,
1160
+ hard_block_suppressed: true,
1161
+ };
1188
1162
  }
1189
1163
 
1190
1164
  /**
@@ -1282,14 +1256,13 @@ export async function recordMFAVerifyFailure(ipAddress: string): Promise<void> {
1282
1256
  ABUSE_THRESHOLDS.MFA_VERIFY_FAILED_WINDOW_MS
1283
1257
  );
1284
1258
 
1285
- void logAbuseEvent(ipAddress, 'mfa_verify_failed', { success: false });
1286
-
1287
- if (count >= ABUSE_THRESHOLDS.MFA_VERIFY_FAILED_MAX) {
1288
- void blockIP(ipAddress, 'mfa_verify_failed', {
1289
- trigger: 'max_failures_exceeded',
1290
- failedCount: count,
1291
- });
1292
- }
1259
+ void logAbuseEvent(ipAddress, 'mfa_verify_failed', {
1260
+ success: false,
1261
+ metadata: createHumanAuthRateLimitMetadata(
1262
+ count,
1263
+ ABUSE_THRESHOLDS.MFA_VERIFY_FAILED_MAX
1264
+ ),
1265
+ });
1293
1266
  }
1294
1267
 
1295
1268
  /**
@@ -1383,11 +1356,13 @@ export async function recordReauthVerifyFailure(
1383
1356
  ABUSE_THRESHOLDS.REAUTH_VERIFY_FAILED_WINDOW_MS
1384
1357
  );
1385
1358
 
1386
- void logAbuseEvent(ipAddress, 'reauth_verify_failed', { success: false });
1387
-
1388
- if (count >= ABUSE_THRESHOLDS.REAUTH_VERIFY_FAILED_MAX) {
1389
- void blockIP(ipAddress, 'reauth_verify_failed');
1390
- }
1359
+ void logAbuseEvent(ipAddress, 'reauth_verify_failed', {
1360
+ success: false,
1361
+ metadata: createHumanAuthRateLimitMetadata(
1362
+ count,
1363
+ ABUSE_THRESHOLDS.REAUTH_VERIFY_FAILED_MAX
1364
+ ),
1365
+ });
1391
1366
  }
1392
1367
 
1393
1368
  /**
@@ -1404,9 +1379,16 @@ export async function clearReauthVerifyFailures(
1404
1379
  */
1405
1380
  export async function checkPasswordLoginLimit(
1406
1381
  ipAddress: string,
1382
+ emailOrContext?: string | AbuseProtectionLogContext,
1407
1383
  context?: AbuseProtectionLogContext
1408
1384
  ): Promise<AbuseCheckResult> {
1409
- const blockInfo = await isIPBlocked(ipAddress, context);
1385
+ const email =
1386
+ typeof emailOrContext === 'string'
1387
+ ? normalizeAbuseEventEmail(emailOrContext)
1388
+ : null;
1389
+ const logContext =
1390
+ typeof emailOrContext === 'string' ? context : emailOrContext;
1391
+ const blockInfo = await isIPBlocked(ipAddress, logContext);
1410
1392
  if (blockInfo) {
1411
1393
  return {
1412
1394
  allowed: false,
@@ -1418,10 +1400,16 @@ export async function checkPasswordLoginLimit(
1418
1400
  };
1419
1401
  }
1420
1402
 
1421
- const key = REDIS_KEYS.PASSWORD_LOGIN_FAILED(ipAddress);
1422
- const count = await getCounter(key);
1403
+ const ipKey = REDIS_KEYS.PASSWORD_LOGIN_FAILED(ipAddress);
1404
+ const emailKey = email
1405
+ ? REDIS_KEYS.PASSWORD_LOGIN_FAILED_EMAIL(hashEmail(email))
1406
+ : null;
1407
+ const [ipCount, emailCount] = await Promise.all([
1408
+ getCounter(ipKey),
1409
+ emailKey ? getCounter(emailKey) : Promise.resolve(0),
1410
+ ]);
1423
1411
 
1424
- if (count >= ABUSE_THRESHOLDS.PASSWORD_LOGIN_FAILED_MAX) {
1412
+ if (ipCount >= ABUSE_THRESHOLDS.PASSWORD_LOGIN_FAILED_MAX) {
1425
1413
  return {
1426
1414
  allowed: false,
1427
1415
  reason: 'Too many failed login attempts',
@@ -1432,9 +1420,25 @@ export async function checkPasswordLoginLimit(
1432
1420
  };
1433
1421
  }
1434
1422
 
1423
+ if (email && emailCount >= ABUSE_THRESHOLDS.PASSWORD_LOGIN_FAILED_EMAIL_MAX) {
1424
+ return {
1425
+ allowed: false,
1426
+ reason: 'Too many failed login attempts for this email',
1427
+ retryAfter: Math.ceil(
1428
+ ABUSE_THRESHOLDS.PASSWORD_LOGIN_FAILED_EMAIL_WINDOW_MS / 1000
1429
+ ),
1430
+ remainingAttempts: 0,
1431
+ };
1432
+ }
1433
+
1435
1434
  return {
1436
1435
  allowed: true,
1437
- remainingAttempts: ABUSE_THRESHOLDS.PASSWORD_LOGIN_FAILED_MAX - count,
1436
+ remainingAttempts: Math.min(
1437
+ ABUSE_THRESHOLDS.PASSWORD_LOGIN_FAILED_MAX - ipCount,
1438
+ email
1439
+ ? ABUSE_THRESHOLDS.PASSWORD_LOGIN_FAILED_EMAIL_MAX - emailCount
1440
+ : ABUSE_THRESHOLDS.PASSWORD_LOGIN_FAILED_MAX - ipCount
1441
+ ),
1438
1442
  };
1439
1443
  }
1440
1444
 
@@ -1445,32 +1449,48 @@ export async function recordPasswordLoginFailure(
1445
1449
  ipAddress: string,
1446
1450
  email?: string
1447
1451
  ): Promise<void> {
1448
- const key = REDIS_KEYS.PASSWORD_LOGIN_FAILED(ipAddress);
1449
- const { count } = await incrementCounter(
1450
- key,
1451
- ABUSE_THRESHOLDS.PASSWORD_LOGIN_FAILED_WINDOW_MS
1452
- );
1452
+ const normalizedEmail = normalizeAbuseEventEmail(email);
1453
+ const ipKey = REDIS_KEYS.PASSWORD_LOGIN_FAILED(ipAddress);
1454
+ const emailKey = normalizedEmail
1455
+ ? REDIS_KEYS.PASSWORD_LOGIN_FAILED_EMAIL(hashEmail(normalizedEmail))
1456
+ : null;
1457
+ const [{ count }] = await Promise.all([
1458
+ incrementCounter(ipKey, ABUSE_THRESHOLDS.PASSWORD_LOGIN_FAILED_WINDOW_MS),
1459
+ emailKey
1460
+ ? incrementCounter(
1461
+ emailKey,
1462
+ ABUSE_THRESHOLDS.PASSWORD_LOGIN_FAILED_EMAIL_WINDOW_MS
1463
+ )
1464
+ : Promise.resolve({ count: 0, ttl: 0 }),
1465
+ ]);
1453
1466
 
1454
1467
  void logAbuseEvent(ipAddress, 'password_login_failed', {
1455
- email,
1468
+ email: normalizedEmail ?? undefined,
1456
1469
  success: false,
1470
+ metadata: createHumanAuthRateLimitMetadata(
1471
+ count,
1472
+ ABUSE_THRESHOLDS.PASSWORD_LOGIN_FAILED_MAX
1473
+ ),
1457
1474
  });
1458
-
1459
- if (count >= ABUSE_THRESHOLDS.PASSWORD_LOGIN_FAILED_MAX) {
1460
- void blockIP(ipAddress, 'password_login_failed', {
1461
- trigger: 'max_failures_exceeded',
1462
- failedCount: count,
1463
- });
1464
- }
1465
1475
  }
1466
1476
 
1467
1477
  /**
1468
- * Clear password login failures on success
1478
+ * Clear password login failures for the successful account only.
1479
+ * The IP-wide failure counter intentionally remains in place so a successful
1480
+ * login for another account cannot reset brute-force pressure from the IP.
1469
1481
  */
1470
1482
  export async function clearPasswordLoginFailures(
1471
- ipAddress: string
1483
+ _ipAddress: string,
1484
+ email?: string
1472
1485
  ): Promise<void> {
1473
- await deleteKeys(REDIS_KEYS.PASSWORD_LOGIN_FAILED(ipAddress));
1486
+ const normalizedEmail = normalizeAbuseEventEmail(email);
1487
+ if (!normalizedEmail) {
1488
+ return;
1489
+ }
1490
+
1491
+ await deleteKeys(
1492
+ REDIS_KEYS.PASSWORD_LOGIN_FAILED_EMAIL(hashEmail(normalizedEmail))
1493
+ );
1474
1494
  }
1475
1495
 
1476
1496
  /**
@@ -23,11 +23,21 @@ export const ABUSE_REPUTATION_SUBJECT_TYPES = [
23
23
  'ip',
24
24
  'cidr',
25
25
  'user_location',
26
+ 'workspace',
26
27
  ] as const;
27
28
 
28
29
  export type AbuseReputationSubjectType =
29
30
  (typeof ABUSE_REPUTATION_SUBJECT_TYPES)[number];
30
31
 
32
+ export const RATE_LIMIT_MODES = [
33
+ 'inherit_multiplier',
34
+ 'absolute',
35
+ 'unlimited',
36
+ 'blocked',
37
+ ] as const;
38
+
39
+ export type RateLimitMode = (typeof RATE_LIMIT_MODES)[number];
40
+
31
41
  export type AbuseSignalType =
32
42
  | 'organic_activity'
33
43
  | 'automation_client'