@tuturuuu/utils 0.8.0 → 0.10.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 +21 -0
- package/biome.json +1 -1
- package/package.json +17 -15
- package/src/__tests__/api-proxy-guard.test.ts +728 -71
- package/src/__tests__/app-url.test.ts +2 -0
- package/src/__tests__/task-description-codec.test.ts +110 -0
- package/src/abuse-protection/__tests__/backend-rate-limit.test.ts +10 -45
- package/src/abuse-protection/__tests__/edge-trust.test.ts +99 -0
- package/src/abuse-protection/__tests__/index.test.ts +136 -0
- package/src/abuse-protection/backend-rate-limit.ts +5 -4
- package/src/abuse-protection/edge-trust.ts +141 -6
- package/src/abuse-protection/index.ts +73 -40
- package/src/api-proxy-guard.ts +202 -43
- 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 +24 -0
- package/src/platform-release.test.ts +1 -1
- package/src/platform-release.ts +1 -1
- package/src/portless.ts +2 -0
- package/src/task-description-codec.ts +457 -0
- package/src/task-helper/task-operations.ts +3 -0
- package/src/yjs-task-description.ts +13 -21
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
} from '../upstash-rest';
|
|
22
22
|
|
|
23
23
|
const TRUST_CACHE_PREFIX = 'trust:mult';
|
|
24
|
+
const APPEAL_RELIEF_CACHE_PREFIX = 'trust:appeal-relief';
|
|
24
25
|
|
|
25
26
|
// Mirrors the DB CHECK constraint on trust_multiplier (> 0 AND <= 1000).
|
|
26
27
|
const MIN_TRUST_MULTIPLIER = 0.35;
|
|
@@ -39,9 +40,12 @@ export interface CachedTrustEntry {
|
|
|
39
40
|
mode?: 'absolute' | 'unlimited';
|
|
40
41
|
/** For mode=absolute: explicit per-window READ limits. */
|
|
41
42
|
abs?: { minute?: number; hour?: number; day?: number };
|
|
43
|
+
/** Server-verified session marker. Does not increase limits by itself. */
|
|
44
|
+
verified?: boolean;
|
|
42
45
|
}
|
|
43
46
|
|
|
44
47
|
const DEFAULT_TRUST_CACHE_TTL_SECONDS = 3600; // 1 hour
|
|
48
|
+
const DEFAULT_APPEAL_RELIEF_TTL_SECONDS = 15 * 60; // 15 minutes
|
|
45
49
|
|
|
46
50
|
let trustRedisClient: UpstashRestRedisClient | null = null;
|
|
47
51
|
let trustRedisInitialized = false;
|
|
@@ -63,6 +67,13 @@ export function getTrustCacheTtlSeconds(): number {
|
|
|
63
67
|
);
|
|
64
68
|
}
|
|
65
69
|
|
|
70
|
+
export function getAppealReliefTtlSeconds(): number {
|
|
71
|
+
return parsePositiveIntEnv(
|
|
72
|
+
'RATE_LIMIT_APPEAL_RELIEF_TTL_SECONDS',
|
|
73
|
+
DEFAULT_APPEAL_RELIEF_TTL_SECONDS
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
66
77
|
async function getTrustRedisClient() {
|
|
67
78
|
if (trustRedisInitialized) return trustRedisClient;
|
|
68
79
|
|
|
@@ -84,6 +95,13 @@ export function buildTrustCacheKey(subjectKey: string): string {
|
|
|
84
95
|
return `${TRUST_CACHE_PREFIX}:${subjectKey}`;
|
|
85
96
|
}
|
|
86
97
|
|
|
98
|
+
export function buildIpBlockAppealReliefKey(
|
|
99
|
+
sessionKey: string,
|
|
100
|
+
ipKey: string
|
|
101
|
+
): string {
|
|
102
|
+
return `${APPEAL_RELIEF_CACHE_PREFIX}:${sessionKey}:${ipKey}`;
|
|
103
|
+
}
|
|
104
|
+
|
|
87
105
|
function clampMultiplier(value: number): number {
|
|
88
106
|
if (!Number.isFinite(value) || value <= 0) {
|
|
89
107
|
return NEUTRAL_TRUST_MULTIPLIER;
|
|
@@ -156,14 +174,25 @@ function parseEntry(value: unknown): CachedTrustEntry | null {
|
|
|
156
174
|
? record.mode
|
|
157
175
|
: undefined;
|
|
158
176
|
const abs = parseAbsLimits(record.abs);
|
|
177
|
+
const verified = record.verified === true;
|
|
159
178
|
|
|
160
|
-
|
|
179
|
+
const entry: CachedTrustEntry = { m };
|
|
180
|
+
if (mode) {
|
|
181
|
+
entry.mode = mode;
|
|
182
|
+
}
|
|
183
|
+
if (abs) {
|
|
184
|
+
entry.abs = abs;
|
|
185
|
+
}
|
|
186
|
+
if (verified) {
|
|
187
|
+
entry.verified = true;
|
|
188
|
+
}
|
|
189
|
+
return entry;
|
|
161
190
|
}
|
|
162
191
|
|
|
163
192
|
/** Serializes an entry for storage: plain number when multiplier-only. */
|
|
164
193
|
function serializeEntry(entry: CachedTrustEntry): number | CachedTrustEntry {
|
|
165
194
|
const m = clampMultiplier(entry.m);
|
|
166
|
-
if (!entry.mode && !entry.abs) {
|
|
195
|
+
if (!entry.mode && !entry.abs && !entry.verified) {
|
|
167
196
|
return m;
|
|
168
197
|
}
|
|
169
198
|
|
|
@@ -182,20 +211,28 @@ function serializeEntry(entry: CachedTrustEntry): number | CachedTrustEntry {
|
|
|
182
211
|
serialized.abs = abs;
|
|
183
212
|
}
|
|
184
213
|
}
|
|
214
|
+
if (entry.verified) {
|
|
215
|
+
serialized.verified = true;
|
|
216
|
+
}
|
|
185
217
|
return serialized;
|
|
186
218
|
}
|
|
187
219
|
|
|
188
220
|
function isActiveEntry(entry: CachedTrustEntry): boolean {
|
|
189
|
-
return
|
|
221
|
+
return (
|
|
222
|
+
entry.mode != null ||
|
|
223
|
+
entry.verified === true ||
|
|
224
|
+
entry.m > NEUTRAL_TRUST_MULTIPLIER
|
|
225
|
+
);
|
|
190
226
|
}
|
|
191
227
|
|
|
192
228
|
/**
|
|
193
229
|
* Resolves the cached trust multiplier for each provided subject key.
|
|
194
230
|
*
|
|
195
231
|
* Returns a map of subject key -> clamped multiplier for keys that have a cache
|
|
196
|
-
* entry above the neutral baseline
|
|
197
|
-
*
|
|
198
|
-
* (
|
|
232
|
+
* entry above the neutral baseline, admin-mode entries, or neutral verified
|
|
233
|
+
* session entries. Missing keys are omitted. Returns an empty map on any Redis
|
|
234
|
+
* error (fail-open). Lets callers distinguish account trust (session key) from
|
|
235
|
+
* location trust (cidr/ip keys).
|
|
199
236
|
*/
|
|
200
237
|
export async function getCachedTrustEntries(
|
|
201
238
|
subjectKeys: string[]
|
|
@@ -342,3 +379,101 @@ export async function writeTrustCacheForSubjects(
|
|
|
342
379
|
// Cache writes must never block the protected request path.
|
|
343
380
|
}
|
|
344
381
|
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* Write-through a neutral marker for server-verified browser/app sessions.
|
|
385
|
+
* This only proves the session cookie was successfully validated downstream; it
|
|
386
|
+
* does not grant a read-limit multiplier or admin override.
|
|
387
|
+
*/
|
|
388
|
+
export async function writeVerifiedSessionCacheForSubjects(
|
|
389
|
+
subjectKeys: string[],
|
|
390
|
+
ttlSeconds: number = getTrustCacheTtlSeconds()
|
|
391
|
+
): Promise<void> {
|
|
392
|
+
const sessionKeys = subjectKeys.filter((subjectKey) =>
|
|
393
|
+
subjectKey.startsWith('session:')
|
|
394
|
+
);
|
|
395
|
+
if (sessionKeys.length === 0) {
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
try {
|
|
400
|
+
const redis = await getTrustRedisClient();
|
|
401
|
+
if (!redis) {
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
const existingEntries = await getCachedTrustEntries(sessionKeys);
|
|
406
|
+
const verifiedEntry = serializeEntry({
|
|
407
|
+
m: NEUTRAL_TRUST_MULTIPLIER,
|
|
408
|
+
verified: true,
|
|
409
|
+
});
|
|
410
|
+
await Promise.all(
|
|
411
|
+
sessionKeys.map((subjectKey) => {
|
|
412
|
+
const existing = existingEntries.get(subjectKey);
|
|
413
|
+
if (
|
|
414
|
+
existing &&
|
|
415
|
+
(existing.mode != null || existing.abs != null || existing.m > 1)
|
|
416
|
+
) {
|
|
417
|
+
return Promise.resolve();
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
return redis.set(buildTrustCacheKey(subjectKey), verifiedEntry, {
|
|
421
|
+
ex: ttlSeconds,
|
|
422
|
+
});
|
|
423
|
+
})
|
|
424
|
+
);
|
|
425
|
+
} catch {
|
|
426
|
+
// Cache writes must never block the protected request path.
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* Grants a short, session+IP-scoped bypass for the hard IP-block check. This is
|
|
432
|
+
* used only after an authenticated user passes Turnstile and submits an appeal;
|
|
433
|
+
* it does not clear the global IP block or elevate unrelated sessions.
|
|
434
|
+
*/
|
|
435
|
+
export async function setCachedIpBlockAppealRelief(
|
|
436
|
+
sessionKey: string,
|
|
437
|
+
ipKey: string,
|
|
438
|
+
ttlSeconds: number = getAppealReliefTtlSeconds()
|
|
439
|
+
): Promise<void> {
|
|
440
|
+
if (!sessionKey || !ipKey) {
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
try {
|
|
445
|
+
const redis = await getTrustRedisClient();
|
|
446
|
+
if (!redis) {
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
await redis.set(buildIpBlockAppealReliefKey(sessionKey, ipKey), '1', {
|
|
451
|
+
ex: ttlSeconds,
|
|
452
|
+
});
|
|
453
|
+
} catch {
|
|
454
|
+
// Cache writes must never block the appeal path.
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
export async function hasCachedIpBlockAppealRelief(
|
|
459
|
+
sessionKey: string,
|
|
460
|
+
ipKey: string
|
|
461
|
+
): Promise<boolean> {
|
|
462
|
+
if (!sessionKey || !ipKey) {
|
|
463
|
+
return false;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
try {
|
|
467
|
+
const redis = await getTrustRedisClient();
|
|
468
|
+
if (!redis) {
|
|
469
|
+
return false;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
return Boolean(
|
|
473
|
+
await redis.get<string>(buildIpBlockAppealReliefKey(sessionKey, ipKey))
|
|
474
|
+
);
|
|
475
|
+
} catch {
|
|
476
|
+
// Fail closed for the hard-block bypass.
|
|
477
|
+
return false;
|
|
478
|
+
}
|
|
479
|
+
}
|
|
@@ -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;
|
|
@@ -396,6 +408,10 @@ export async function blockIP(
|
|
|
396
408
|
reason: AbuseEventType,
|
|
397
409
|
metadata?: Record<string, unknown>
|
|
398
410
|
): Promise<void> {
|
|
411
|
+
if (isSharedIpThrottleOnlyBlockReason(reason)) {
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
414
|
+
|
|
399
415
|
try {
|
|
400
416
|
const sbAdmin = await getSupabaseAdmin();
|
|
401
417
|
if (!sbAdmin) return;
|
|
@@ -900,18 +916,15 @@ export async function checkOTPSendAllowed(
|
|
|
900
916
|
const ipLimits = getOTPSendIpLimits();
|
|
901
917
|
|
|
902
918
|
if (minuteState.count > ipLimits.perMinute) {
|
|
903
|
-
// Log and potentially block
|
|
904
919
|
void logAbuseEvent(ipAddress, 'otp_send', {
|
|
905
920
|
email,
|
|
906
921
|
success: false,
|
|
907
|
-
metadata: {
|
|
922
|
+
metadata: {
|
|
923
|
+
trigger: 'minute_limit',
|
|
924
|
+
hard_block_suppressed: minuteState.count >= ipLimits.perMinute * 2,
|
|
925
|
+
},
|
|
908
926
|
});
|
|
909
927
|
|
|
910
|
-
if (minuteState.count >= ipLimits.perMinute * 2) {
|
|
911
|
-
// Aggressive abuse - block IP
|
|
912
|
-
void blockIP(ipAddress, 'otp_send', { trigger: 'rate_limit_exceeded' });
|
|
913
|
-
}
|
|
914
|
-
|
|
915
928
|
return {
|
|
916
929
|
allowed: false,
|
|
917
930
|
reason: 'Too many OTP requests. Please try again later.',
|
|
@@ -924,11 +937,12 @@ export async function checkOTPSendAllowed(
|
|
|
924
937
|
void logAbuseEvent(ipAddress, 'otp_send', {
|
|
925
938
|
email,
|
|
926
939
|
success: false,
|
|
927
|
-
metadata: {
|
|
940
|
+
metadata: {
|
|
941
|
+
trigger: 'hourly_rate_limit',
|
|
942
|
+
hard_block_suppressed: true,
|
|
943
|
+
},
|
|
928
944
|
});
|
|
929
945
|
|
|
930
|
-
void blockIP(ipAddress, 'otp_send', { trigger: 'hourly_rate_limit' });
|
|
931
|
-
|
|
932
946
|
return {
|
|
933
947
|
allowed: false,
|
|
934
948
|
reason: 'Hourly OTP limit reached. Please try again later.',
|
|
@@ -941,9 +955,11 @@ export async function checkOTPSendAllowed(
|
|
|
941
955
|
void logAbuseEvent(ipAddress, 'otp_send', {
|
|
942
956
|
email,
|
|
943
957
|
success: false,
|
|
944
|
-
metadata: {
|
|
958
|
+
metadata: {
|
|
959
|
+
trigger: 'ip_daily_limit',
|
|
960
|
+
hard_block_suppressed: true,
|
|
961
|
+
},
|
|
945
962
|
});
|
|
946
|
-
void blockIP(ipAddress, 'otp_send', { trigger: 'ip_daily_limit' });
|
|
947
963
|
|
|
948
964
|
return {
|
|
949
965
|
allowed: false,
|
|
@@ -1114,16 +1130,35 @@ export async function recordOTPVerifyFailure(
|
|
|
1114
1130
|
),
|
|
1115
1131
|
]);
|
|
1116
1132
|
|
|
1117
|
-
|
|
1118
|
-
|
|
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
|
+
}
|
|
1119
1142
|
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
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;
|
|
1126
1155
|
}
|
|
1156
|
+
|
|
1157
|
+
return {
|
|
1158
|
+
trigger: 'max_failures_exceeded',
|
|
1159
|
+
failedCount,
|
|
1160
|
+
hard_block_suppressed: true,
|
|
1161
|
+
};
|
|
1127
1162
|
}
|
|
1128
1163
|
|
|
1129
1164
|
/**
|
|
@@ -1221,14 +1256,13 @@ export async function recordMFAVerifyFailure(ipAddress: string): Promise<void> {
|
|
|
1221
1256
|
ABUSE_THRESHOLDS.MFA_VERIFY_FAILED_WINDOW_MS
|
|
1222
1257
|
);
|
|
1223
1258
|
|
|
1224
|
-
void logAbuseEvent(ipAddress, 'mfa_verify_failed', {
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
}
|
|
1259
|
+
void logAbuseEvent(ipAddress, 'mfa_verify_failed', {
|
|
1260
|
+
success: false,
|
|
1261
|
+
metadata: createHumanAuthRateLimitMetadata(
|
|
1262
|
+
count,
|
|
1263
|
+
ABUSE_THRESHOLDS.MFA_VERIFY_FAILED_MAX
|
|
1264
|
+
),
|
|
1265
|
+
});
|
|
1232
1266
|
}
|
|
1233
1267
|
|
|
1234
1268
|
/**
|
|
@@ -1322,11 +1356,13 @@ export async function recordReauthVerifyFailure(
|
|
|
1322
1356
|
ABUSE_THRESHOLDS.REAUTH_VERIFY_FAILED_WINDOW_MS
|
|
1323
1357
|
);
|
|
1324
1358
|
|
|
1325
|
-
void logAbuseEvent(ipAddress, 'reauth_verify_failed', {
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1359
|
+
void logAbuseEvent(ipAddress, 'reauth_verify_failed', {
|
|
1360
|
+
success: false,
|
|
1361
|
+
metadata: createHumanAuthRateLimitMetadata(
|
|
1362
|
+
count,
|
|
1363
|
+
ABUSE_THRESHOLDS.REAUTH_VERIFY_FAILED_MAX
|
|
1364
|
+
),
|
|
1365
|
+
});
|
|
1330
1366
|
}
|
|
1331
1367
|
|
|
1332
1368
|
/**
|
|
@@ -1431,14 +1467,11 @@ export async function recordPasswordLoginFailure(
|
|
|
1431
1467
|
void logAbuseEvent(ipAddress, 'password_login_failed', {
|
|
1432
1468
|
email: normalizedEmail ?? undefined,
|
|
1433
1469
|
success: false,
|
|
1470
|
+
metadata: createHumanAuthRateLimitMetadata(
|
|
1471
|
+
count,
|
|
1472
|
+
ABUSE_THRESHOLDS.PASSWORD_LOGIN_FAILED_MAX
|
|
1473
|
+
),
|
|
1434
1474
|
});
|
|
1435
|
-
|
|
1436
|
-
if (count >= ABUSE_THRESHOLDS.PASSWORD_LOGIN_FAILED_MAX) {
|
|
1437
|
-
void blockIP(ipAddress, 'password_login_failed', {
|
|
1438
|
-
trigger: 'max_failures_exceeded',
|
|
1439
|
-
failedCount: count,
|
|
1440
|
-
});
|
|
1441
|
-
}
|
|
1442
1475
|
}
|
|
1443
1476
|
|
|
1444
1477
|
/**
|