@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.
- package/CHANGELOG.md +42 -0
- package/biome.json +1 -1
- package/package.json +18 -15
- package/src/__tests__/api-proxy-guard.test.ts +980 -62
- package/src/__tests__/app-url.test.ts +22 -0
- package/src/__tests__/upstash-rest.test.ts +3 -0
- package/src/abuse-protection/__tests__/backend-rate-limit.test.ts +10 -45
- package/src/abuse-protection/__tests__/edge-trust.test.ts +258 -0
- package/src/abuse-protection/__tests__/index.test.ts +198 -6
- package/src/abuse-protection/backend-rate-limit.ts +5 -4
- package/src/abuse-protection/constants.ts +4 -0
- package/src/abuse-protection/edge-trust.ts +412 -0
- package/src/abuse-protection/index.ts +158 -138
- package/src/abuse-protection/reputation.ts +10 -0
- package/src/api-proxy-guard.ts +448 -27
- package/src/currencies.test.ts +33 -0
- package/src/currencies.ts +28 -5
- package/src/effect.test.ts +247 -0
- package/src/effect.ts +259 -0
- package/src/internal-domains.ts +78 -2
- package/src/money.test.ts +82 -0
- package/src/money.ts +120 -0
- package/src/next-config.test.ts +13 -0
- package/src/next-config.ts +0 -8
- package/src/platform-release.test.ts +1 -1
- package/src/platform-release.ts +1 -1
- package/src/portless.ts +2 -0
- package/src/task-helper/task-operations.ts +3 -0
- package/src/upstash-rest.ts +3 -1
- package/src/workspace-helper.ts +28 -1
|
@@ -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
|
|
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
|
-
|
|
932
|
-
|
|
933
|
-
|
|
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
|
|
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: {
|
|
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
|
|
936
|
+
if (hourlyState.count > ipLimits.perHour) {
|
|
959
937
|
void logAbuseEvent(ipAddress, 'otp_send', {
|
|
960
938
|
email,
|
|
961
939
|
success: false,
|
|
962
|
-
metadata: {
|
|
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
|
|
954
|
+
if (dailyState.count > ipLimits.perDay) {
|
|
976
955
|
void logAbuseEvent(ipAddress, 'otp_send', {
|
|
977
956
|
email,
|
|
978
957
|
success: false,
|
|
979
|
-
metadata: {
|
|
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
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
1179
|
-
|
|
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
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
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', {
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
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', {
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
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
|
|
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
|
|
1422
|
-
const
|
|
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 (
|
|
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:
|
|
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
|
|
1449
|
-
const
|
|
1450
|
-
|
|
1451
|
-
|
|
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
|
|
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
|
-
|
|
1483
|
+
_ipAddress: string,
|
|
1484
|
+
email?: string
|
|
1472
1485
|
): Promise<void> {
|
|
1473
|
-
|
|
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'
|