@tuturuuu/utils 0.6.1 → 0.8.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 +41 -0
- package/biome.json +1 -1
- package/package.json +5 -4
- package/src/__tests__/api-proxy-guard.test.ts +503 -12
- package/src/__tests__/app-url.test.ts +20 -0
- package/src/__tests__/upstash-rest.test.ts +3 -0
- package/src/abuse-protection/__tests__/edge-trust.test.ts +200 -0
- package/src/abuse-protection/__tests__/index.test.ts +64 -8
- package/src/abuse-protection/constants.ts +4 -0
- package/src/abuse-protection/edge-trust.ts +344 -0
- package/src/abuse-protection/index.ts +85 -98
- package/src/abuse-protection/reputation.ts +10 -0
- package/src/api-proxy-guard.ts +371 -24
- package/src/internal-domains.ts +54 -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/permissions.tsx +19 -0
- package/src/platform-release.test.ts +1 -1
- package/src/platform-release.ts +1 -1
- package/src/upstash-rest.ts +3 -1
- package/src/workspace-helper.ts +28 -1
|
@@ -239,44 +239,6 @@ async function getCounter(key: string): Promise<number> {
|
|
|
239
239
|
return existing.count;
|
|
240
240
|
}
|
|
241
241
|
|
|
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
242
|
/**
|
|
281
243
|
* Delete keys from Redis or memory
|
|
282
244
|
*/
|
|
@@ -902,7 +864,10 @@ export async function resetOtpLimitsForEmail({
|
|
|
902
864
|
}
|
|
903
865
|
|
|
904
866
|
/**
|
|
905
|
-
* Check and
|
|
867
|
+
* Check and reserve OTP send attempts.
|
|
868
|
+
*
|
|
869
|
+
* Accepted sends consume quota before provider delivery to prevent parallel
|
|
870
|
+
* requests from all observing the same pre-send counter state.
|
|
906
871
|
*/
|
|
907
872
|
export async function checkOTPSendAllowed(
|
|
908
873
|
ipAddress: string,
|
|
@@ -928,13 +893,13 @@ export async function checkOTPSendAllowed(
|
|
|
928
893
|
const dailyKey = REDIS_KEYS.OTP_SEND_DAILY(ipAddress);
|
|
929
894
|
|
|
930
895
|
const [minuteState, hourlyState, dailyState] = await Promise.all([
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
896
|
+
incrementCounter(minuteKey, WINDOW_MS.ONE_MINUTE),
|
|
897
|
+
incrementCounter(hourlyKey, WINDOW_MS.ONE_HOUR),
|
|
898
|
+
incrementCounter(dailyKey, WINDOW_MS.TWENTY_FOUR_HOURS),
|
|
934
899
|
]);
|
|
935
900
|
const ipLimits = getOTPSendIpLimits();
|
|
936
901
|
|
|
937
|
-
if (minuteState.count
|
|
902
|
+
if (minuteState.count > ipLimits.perMinute) {
|
|
938
903
|
// Log and potentially block
|
|
939
904
|
void logAbuseEvent(ipAddress, 'otp_send', {
|
|
940
905
|
email,
|
|
@@ -955,7 +920,7 @@ export async function checkOTPSendAllowed(
|
|
|
955
920
|
};
|
|
956
921
|
}
|
|
957
922
|
|
|
958
|
-
if (hourlyState.count
|
|
923
|
+
if (hourlyState.count > ipLimits.perHour) {
|
|
959
924
|
void logAbuseEvent(ipAddress, 'otp_send', {
|
|
960
925
|
email,
|
|
961
926
|
success: false,
|
|
@@ -972,7 +937,7 @@ export async function checkOTPSendAllowed(
|
|
|
972
937
|
};
|
|
973
938
|
}
|
|
974
939
|
|
|
975
|
-
if (dailyState.count
|
|
940
|
+
if (dailyState.count > ipLimits.perDay) {
|
|
976
941
|
void logAbuseEvent(ipAddress, 'otp_send', {
|
|
977
942
|
email,
|
|
978
943
|
success: false,
|
|
@@ -995,14 +960,11 @@ export async function checkOTPSendAllowed(
|
|
|
995
960
|
const hourlyEmailKey = REDIS_KEYS.OTP_SEND_EMAIL_HOURLY(emailHash);
|
|
996
961
|
const dailyEmailKey = REDIS_KEYS.OTP_SEND_EMAIL_DAILY(emailHash);
|
|
997
962
|
|
|
998
|
-
const
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
]);
|
|
1004
|
-
|
|
1005
|
-
if (cooldownState.count >= 1) {
|
|
963
|
+
const cooldownState = await incrementCounter(
|
|
964
|
+
cooldownKey,
|
|
965
|
+
ABUSE_THRESHOLDS.OTP_SEND_EMAIL_COOLDOWN_WINDOW_MS
|
|
966
|
+
);
|
|
967
|
+
if (cooldownState.count > 1) {
|
|
1006
968
|
void logAbuseEvent(ipAddress, 'otp_send', {
|
|
1007
969
|
email,
|
|
1008
970
|
success: false,
|
|
@@ -1017,7 +979,12 @@ export async function checkOTPSendAllowed(
|
|
|
1017
979
|
};
|
|
1018
980
|
}
|
|
1019
981
|
|
|
1020
|
-
|
|
982
|
+
const [hourlyEmailState, dailyEmailState] = await Promise.all([
|
|
983
|
+
incrementCounter(hourlyEmailKey, WINDOW_MS.ONE_HOUR),
|
|
984
|
+
incrementCounter(dailyEmailKey, WINDOW_MS.TWENTY_FOUR_HOURS),
|
|
985
|
+
]);
|
|
986
|
+
|
|
987
|
+
if (hourlyEmailState.count > ABUSE_THRESHOLDS.OTP_SEND_EMAIL_PER_HOUR) {
|
|
1021
988
|
void logAbuseEvent(ipAddress, 'otp_send', {
|
|
1022
989
|
email,
|
|
1023
990
|
success: false,
|
|
@@ -1032,7 +999,7 @@ export async function checkOTPSendAllowed(
|
|
|
1032
999
|
};
|
|
1033
1000
|
}
|
|
1034
1001
|
|
|
1035
|
-
if (dailyEmailState.count
|
|
1002
|
+
if (dailyEmailState.count > ABUSE_THRESHOLDS.OTP_SEND_EMAIL_PER_DAY) {
|
|
1036
1003
|
void logAbuseEvent(ipAddress, 'otp_send', {
|
|
1037
1004
|
email,
|
|
1038
1005
|
success: false,
|
|
@@ -1050,10 +1017,7 @@ export async function checkOTPSendAllowed(
|
|
|
1050
1017
|
|
|
1051
1018
|
return {
|
|
1052
1019
|
allowed: true,
|
|
1053
|
-
remainingAttempts: Math.max(
|
|
1054
|
-
0,
|
|
1055
|
-
ipLimits.perMinute - (minuteState.count + 1)
|
|
1056
|
-
),
|
|
1020
|
+
remainingAttempts: Math.max(0, ipLimits.perMinute - minuteState.count),
|
|
1057
1021
|
};
|
|
1058
1022
|
}
|
|
1059
1023
|
|
|
@@ -1061,31 +1025,6 @@ export async function recordOTPSendSuccess(
|
|
|
1061
1025
|
ipAddress: string,
|
|
1062
1026
|
email?: string
|
|
1063
1027
|
): 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
1028
|
void logAbuseEvent(ipAddress, 'otp_send', { email, success: true });
|
|
1090
1029
|
}
|
|
1091
1030
|
|
|
@@ -1404,9 +1343,16 @@ export async function clearReauthVerifyFailures(
|
|
|
1404
1343
|
*/
|
|
1405
1344
|
export async function checkPasswordLoginLimit(
|
|
1406
1345
|
ipAddress: string,
|
|
1346
|
+
emailOrContext?: string | AbuseProtectionLogContext,
|
|
1407
1347
|
context?: AbuseProtectionLogContext
|
|
1408
1348
|
): Promise<AbuseCheckResult> {
|
|
1409
|
-
const
|
|
1349
|
+
const email =
|
|
1350
|
+
typeof emailOrContext === 'string'
|
|
1351
|
+
? normalizeAbuseEventEmail(emailOrContext)
|
|
1352
|
+
: null;
|
|
1353
|
+
const logContext =
|
|
1354
|
+
typeof emailOrContext === 'string' ? context : emailOrContext;
|
|
1355
|
+
const blockInfo = await isIPBlocked(ipAddress, logContext);
|
|
1410
1356
|
if (blockInfo) {
|
|
1411
1357
|
return {
|
|
1412
1358
|
allowed: false,
|
|
@@ -1418,10 +1364,16 @@ export async function checkPasswordLoginLimit(
|
|
|
1418
1364
|
};
|
|
1419
1365
|
}
|
|
1420
1366
|
|
|
1421
|
-
const
|
|
1422
|
-
const
|
|
1367
|
+
const ipKey = REDIS_KEYS.PASSWORD_LOGIN_FAILED(ipAddress);
|
|
1368
|
+
const emailKey = email
|
|
1369
|
+
? REDIS_KEYS.PASSWORD_LOGIN_FAILED_EMAIL(hashEmail(email))
|
|
1370
|
+
: null;
|
|
1371
|
+
const [ipCount, emailCount] = await Promise.all([
|
|
1372
|
+
getCounter(ipKey),
|
|
1373
|
+
emailKey ? getCounter(emailKey) : Promise.resolve(0),
|
|
1374
|
+
]);
|
|
1423
1375
|
|
|
1424
|
-
if (
|
|
1376
|
+
if (ipCount >= ABUSE_THRESHOLDS.PASSWORD_LOGIN_FAILED_MAX) {
|
|
1425
1377
|
return {
|
|
1426
1378
|
allowed: false,
|
|
1427
1379
|
reason: 'Too many failed login attempts',
|
|
@@ -1432,9 +1384,25 @@ export async function checkPasswordLoginLimit(
|
|
|
1432
1384
|
};
|
|
1433
1385
|
}
|
|
1434
1386
|
|
|
1387
|
+
if (email && emailCount >= ABUSE_THRESHOLDS.PASSWORD_LOGIN_FAILED_EMAIL_MAX) {
|
|
1388
|
+
return {
|
|
1389
|
+
allowed: false,
|
|
1390
|
+
reason: 'Too many failed login attempts for this email',
|
|
1391
|
+
retryAfter: Math.ceil(
|
|
1392
|
+
ABUSE_THRESHOLDS.PASSWORD_LOGIN_FAILED_EMAIL_WINDOW_MS / 1000
|
|
1393
|
+
),
|
|
1394
|
+
remainingAttempts: 0,
|
|
1395
|
+
};
|
|
1396
|
+
}
|
|
1397
|
+
|
|
1435
1398
|
return {
|
|
1436
1399
|
allowed: true,
|
|
1437
|
-
remainingAttempts:
|
|
1400
|
+
remainingAttempts: Math.min(
|
|
1401
|
+
ABUSE_THRESHOLDS.PASSWORD_LOGIN_FAILED_MAX - ipCount,
|
|
1402
|
+
email
|
|
1403
|
+
? ABUSE_THRESHOLDS.PASSWORD_LOGIN_FAILED_EMAIL_MAX - emailCount
|
|
1404
|
+
: ABUSE_THRESHOLDS.PASSWORD_LOGIN_FAILED_MAX - ipCount
|
|
1405
|
+
),
|
|
1438
1406
|
};
|
|
1439
1407
|
}
|
|
1440
1408
|
|
|
@@ -1445,14 +1413,23 @@ export async function recordPasswordLoginFailure(
|
|
|
1445
1413
|
ipAddress: string,
|
|
1446
1414
|
email?: string
|
|
1447
1415
|
): Promise<void> {
|
|
1448
|
-
const
|
|
1449
|
-
const
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1416
|
+
const normalizedEmail = normalizeAbuseEventEmail(email);
|
|
1417
|
+
const ipKey = REDIS_KEYS.PASSWORD_LOGIN_FAILED(ipAddress);
|
|
1418
|
+
const emailKey = normalizedEmail
|
|
1419
|
+
? REDIS_KEYS.PASSWORD_LOGIN_FAILED_EMAIL(hashEmail(normalizedEmail))
|
|
1420
|
+
: null;
|
|
1421
|
+
const [{ count }] = await Promise.all([
|
|
1422
|
+
incrementCounter(ipKey, ABUSE_THRESHOLDS.PASSWORD_LOGIN_FAILED_WINDOW_MS),
|
|
1423
|
+
emailKey
|
|
1424
|
+
? incrementCounter(
|
|
1425
|
+
emailKey,
|
|
1426
|
+
ABUSE_THRESHOLDS.PASSWORD_LOGIN_FAILED_EMAIL_WINDOW_MS
|
|
1427
|
+
)
|
|
1428
|
+
: Promise.resolve({ count: 0, ttl: 0 }),
|
|
1429
|
+
]);
|
|
1453
1430
|
|
|
1454
1431
|
void logAbuseEvent(ipAddress, 'password_login_failed', {
|
|
1455
|
-
email,
|
|
1432
|
+
email: normalizedEmail ?? undefined,
|
|
1456
1433
|
success: false,
|
|
1457
1434
|
});
|
|
1458
1435
|
|
|
@@ -1465,12 +1442,22 @@ export async function recordPasswordLoginFailure(
|
|
|
1465
1442
|
}
|
|
1466
1443
|
|
|
1467
1444
|
/**
|
|
1468
|
-
* Clear password login failures
|
|
1445
|
+
* Clear password login failures for the successful account only.
|
|
1446
|
+
* The IP-wide failure counter intentionally remains in place so a successful
|
|
1447
|
+
* login for another account cannot reset brute-force pressure from the IP.
|
|
1469
1448
|
*/
|
|
1470
1449
|
export async function clearPasswordLoginFailures(
|
|
1471
|
-
|
|
1450
|
+
_ipAddress: string,
|
|
1451
|
+
email?: string
|
|
1472
1452
|
): Promise<void> {
|
|
1473
|
-
|
|
1453
|
+
const normalizedEmail = normalizeAbuseEventEmail(email);
|
|
1454
|
+
if (!normalizedEmail) {
|
|
1455
|
+
return;
|
|
1456
|
+
}
|
|
1457
|
+
|
|
1458
|
+
await deleteKeys(
|
|
1459
|
+
REDIS_KEYS.PASSWORD_LOGIN_FAILED_EMAIL(hashEmail(normalizedEmail))
|
|
1460
|
+
);
|
|
1474
1461
|
}
|
|
1475
1462
|
|
|
1476
1463
|
/**
|
|
@@ -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'
|