@tuturuuu/utils 0.8.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 +14 -0
- package/biome.json +1 -1
- package/package.json +17 -15
- package/src/__tests__/api-proxy-guard.test.ts +513 -2
- package/src/__tests__/app-url.test.ts +2 -0
- package/src/abuse-protection/__tests__/backend-rate-limit.test.ts +10 -45
- package/src/abuse-protection/__tests__/edge-trust.test.ts +58 -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 +74 -6
- package/src/abuse-protection/index.ts +73 -40
- package/src/api-proxy-guard.ts +131 -33
- 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-helper/task-operations.ts +3 -0
|
@@ -2,8 +2,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
2
2
|
|
|
3
3
|
const mocks = vi.hoisted(() => ({
|
|
4
4
|
blockIPEdge: vi.fn(),
|
|
5
|
-
checkUserSuspension: vi.fn(),
|
|
6
|
-
suspendUser: vi.fn(),
|
|
7
5
|
}));
|
|
8
6
|
|
|
9
7
|
vi.mock('../edge', () => ({
|
|
@@ -11,14 +9,6 @@ vi.mock('../edge', () => ({
|
|
|
11
9
|
mocks.blockIPEdge(...args),
|
|
12
10
|
}));
|
|
13
11
|
|
|
14
|
-
vi.mock('../user-suspension', () => ({
|
|
15
|
-
checkUserSuspension: (
|
|
16
|
-
...args: Parameters<typeof mocks.checkUserSuspension>
|
|
17
|
-
) => mocks.checkUserSuspension(...args),
|
|
18
|
-
suspendUser: (...args: Parameters<typeof mocks.suspendUser>) =>
|
|
19
|
-
mocks.suspendUser(...args),
|
|
20
|
-
}));
|
|
21
|
-
|
|
22
12
|
import {
|
|
23
13
|
cascadeBackendRateLimitToProxyBan,
|
|
24
14
|
isBackendRateLimitError,
|
|
@@ -28,8 +18,6 @@ describe('backend-rate-limit', () => {
|
|
|
28
18
|
beforeEach(() => {
|
|
29
19
|
vi.clearAllMocks();
|
|
30
20
|
mocks.blockIPEdge.mockResolvedValue(null);
|
|
31
|
-
mocks.checkUserSuspension.mockResolvedValue({ suspended: false });
|
|
32
|
-
mocks.suspendUser.mockResolvedValue(true);
|
|
33
21
|
});
|
|
34
22
|
|
|
35
23
|
describe('isBackendRateLimitError', () => {
|
|
@@ -51,16 +39,7 @@ describe('backend-rate-limit', () => {
|
|
|
51
39
|
});
|
|
52
40
|
|
|
53
41
|
describe('cascadeBackendRateLimitToProxyBan', () => {
|
|
54
|
-
it('
|
|
55
|
-
const blockInfo = {
|
|
56
|
-
id: 'block-1',
|
|
57
|
-
blockLevel: 1,
|
|
58
|
-
reason: 'api_abuse',
|
|
59
|
-
blockedAt: new Date(),
|
|
60
|
-
expiresAt: new Date(Date.now() + 300_000),
|
|
61
|
-
};
|
|
62
|
-
mocks.blockIPEdge.mockResolvedValue(blockInfo);
|
|
63
|
-
|
|
42
|
+
it('does not hard-ban a known client IP for a backend rate-limit signal', async () => {
|
|
64
43
|
const result = await cascadeBackendRateLimitToProxyBan({
|
|
65
44
|
endpoint: '/api/ai/chat/new',
|
|
66
45
|
ipAddress: '203.0.113.10',
|
|
@@ -68,46 +47,32 @@ describe('backend-rate-limit', () => {
|
|
|
68
47
|
userId: 'user-1',
|
|
69
48
|
});
|
|
70
49
|
|
|
71
|
-
expect(result).
|
|
72
|
-
expect(mocks.blockIPEdge).
|
|
73
|
-
'203.0.113.10',
|
|
74
|
-
'api_abuse'
|
|
75
|
-
);
|
|
76
|
-
expect(mocks.checkUserSuspension).not.toHaveBeenCalled();
|
|
77
|
-
expect(mocks.suspendUser).not.toHaveBeenCalled();
|
|
50
|
+
expect(result).toBeNull();
|
|
51
|
+
expect(mocks.blockIPEdge).not.toHaveBeenCalled();
|
|
78
52
|
});
|
|
79
53
|
|
|
80
|
-
it('does not
|
|
81
|
-
|
|
82
|
-
suspended: true,
|
|
83
|
-
reason: 'already suspended',
|
|
84
|
-
});
|
|
85
|
-
|
|
86
|
-
await cascadeBackendRateLimitToProxyBan({
|
|
54
|
+
it('does not hard-ban auth backend rate-limit signals', async () => {
|
|
55
|
+
const result = await cascadeBackendRateLimitToProxyBan({
|
|
87
56
|
endpoint: '/api/ai/chat/new',
|
|
88
57
|
ipAddress: '203.0.113.10',
|
|
89
58
|
source: 'auth',
|
|
90
59
|
userId: 'user-1',
|
|
91
60
|
});
|
|
92
61
|
|
|
93
|
-
expect(
|
|
94
|
-
expect(mocks.blockIPEdge).
|
|
95
|
-
'203.0.113.10',
|
|
96
|
-
'api_abuse'
|
|
97
|
-
);
|
|
98
|
-
expect(mocks.checkUserSuspension).not.toHaveBeenCalled();
|
|
62
|
+
expect(result).toBeNull();
|
|
63
|
+
expect(mocks.blockIPEdge).not.toHaveBeenCalled();
|
|
99
64
|
});
|
|
100
65
|
|
|
101
|
-
it('
|
|
102
|
-
await cascadeBackendRateLimitToProxyBan({
|
|
66
|
+
it('returns null when the client IP is unavailable', async () => {
|
|
67
|
+
const result = await cascadeBackendRateLimitToProxyBan({
|
|
103
68
|
endpoint: '/api/ai/chat/new',
|
|
104
69
|
ipAddress: 'unknown',
|
|
105
70
|
source: 'database',
|
|
106
71
|
userId: 'user-1',
|
|
107
72
|
});
|
|
108
73
|
|
|
74
|
+
expect(result).toBeNull();
|
|
109
75
|
expect(mocks.blockIPEdge).not.toHaveBeenCalled();
|
|
110
|
-
expect(mocks.suspendUser).not.toHaveBeenCalled();
|
|
111
76
|
});
|
|
112
77
|
});
|
|
113
78
|
});
|
|
@@ -80,6 +80,17 @@ describe('edge-trust cache', () => {
|
|
|
80
80
|
expect(await getCachedTrustMultiplier(['session:aaa'])).toBe(2.5);
|
|
81
81
|
});
|
|
82
82
|
|
|
83
|
+
it('returns neutral verified session entries without treating plain neutral entries as active', async () => {
|
|
84
|
+
mockRedisClient();
|
|
85
|
+
mocks.mget.mockResolvedValue([{ m: 1, verified: true }, 1]);
|
|
86
|
+
|
|
87
|
+
const { getCachedTrustEntries } = await import('../edge-trust.js');
|
|
88
|
+
const entries = await getCachedTrustEntries(['session:aaa', 'session:bbb']);
|
|
89
|
+
|
|
90
|
+
expect(entries.get('session:aaa')).toEqual({ m: 1, verified: true });
|
|
91
|
+
expect(entries.has('session:bbb')).toBe(false);
|
|
92
|
+
});
|
|
93
|
+
|
|
83
94
|
it('fails open to a neutral multiplier when Redis errors', async () => {
|
|
84
95
|
mockRedisClient();
|
|
85
96
|
mocks.mget.mockRejectedValue(new Error('redis down'));
|
|
@@ -139,6 +150,53 @@ describe('edge-trust cache', () => {
|
|
|
139
150
|
});
|
|
140
151
|
});
|
|
141
152
|
|
|
153
|
+
it('writes neutral verified session markers without writing non-session subjects', async () => {
|
|
154
|
+
mockRedisClient();
|
|
155
|
+
mocks.mget.mockResolvedValue([null]);
|
|
156
|
+
mocks.set.mockResolvedValue('OK');
|
|
157
|
+
|
|
158
|
+
const { writeVerifiedSessionCacheForSubjects } = await import(
|
|
159
|
+
'../edge-trust.js'
|
|
160
|
+
);
|
|
161
|
+
await writeVerifiedSessionCacheForSubjects(
|
|
162
|
+
['session:aaa', 'cidr:1.2.3.0/24'],
|
|
163
|
+
600
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
expect(mocks.set).toHaveBeenCalledTimes(1);
|
|
167
|
+
expect(mocks.set).toHaveBeenCalledWith(
|
|
168
|
+
'trust:mult:session:aaa',
|
|
169
|
+
{ m: 1, verified: true },
|
|
170
|
+
{ ex: 600 }
|
|
171
|
+
);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it('does not downgrade elevated trust entries when writing verified session markers', async () => {
|
|
175
|
+
mockRedisClient();
|
|
176
|
+
mocks.mget.mockResolvedValue([3, null]);
|
|
177
|
+
mocks.set.mockResolvedValue('OK');
|
|
178
|
+
|
|
179
|
+
const { writeVerifiedSessionCacheForSubjects } = await import(
|
|
180
|
+
'../edge-trust.js'
|
|
181
|
+
);
|
|
182
|
+
await writeVerifiedSessionCacheForSubjects(
|
|
183
|
+
['session:elevated', 'session:neutral'],
|
|
184
|
+
600
|
|
185
|
+
);
|
|
186
|
+
|
|
187
|
+
expect(mocks.set).toHaveBeenCalledTimes(1);
|
|
188
|
+
expect(mocks.set).toHaveBeenCalledWith(
|
|
189
|
+
'trust:mult:session:neutral',
|
|
190
|
+
{ m: 1, verified: true },
|
|
191
|
+
{ ex: 600 }
|
|
192
|
+
);
|
|
193
|
+
expect(mocks.set).not.toHaveBeenCalledWith(
|
|
194
|
+
'trust:mult:session:elevated',
|
|
195
|
+
expect.anything(),
|
|
196
|
+
expect.anything()
|
|
197
|
+
);
|
|
198
|
+
});
|
|
199
|
+
|
|
142
200
|
it('parses legacy plain numbers and rich JSON entries together', async () => {
|
|
143
201
|
mockRedisClient();
|
|
144
202
|
mocks.mget.mockResolvedValue([
|
|
@@ -3,19 +3,27 @@
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
6
|
+
|
|
6
7
|
import {
|
|
7
8
|
ABUSE_THRESHOLDS,
|
|
8
9
|
BLOCK_DURATIONS,
|
|
10
|
+
checkMFAVerifyLimit,
|
|
9
11
|
checkOTPSendAllowed,
|
|
12
|
+
checkOTPVerifyLimit,
|
|
10
13
|
checkPasswordLoginLimit,
|
|
14
|
+
checkReauthVerifyLimit,
|
|
11
15
|
classifyPotentialSpamUserAgent,
|
|
12
16
|
clearPasswordLoginFailures,
|
|
13
17
|
extractIPFromHeaders,
|
|
14
18
|
hashEmail,
|
|
19
|
+
isSharedIpThrottleOnlyBlockReason,
|
|
15
20
|
MAX_BLOCK_LEVEL,
|
|
16
21
|
REDIS_KEYS,
|
|
22
|
+
recordMFAVerifyFailure,
|
|
17
23
|
recordOTPSendSuccess,
|
|
24
|
+
recordOTPVerifyFailure,
|
|
18
25
|
recordPasswordLoginFailure,
|
|
26
|
+
recordReauthVerifyFailure,
|
|
19
27
|
resetOtpLimitsForEmail,
|
|
20
28
|
WINDOW_MS,
|
|
21
29
|
} from '../index';
|
|
@@ -251,6 +259,23 @@ describe('abuse-protection', () => {
|
|
|
251
259
|
});
|
|
252
260
|
});
|
|
253
261
|
|
|
262
|
+
describe('shared-IP hard block suppression', () => {
|
|
263
|
+
it('keeps human-auth failure reasons throttle-only', () => {
|
|
264
|
+
expect(isSharedIpThrottleOnlyBlockReason('otp_send')).toBe(true);
|
|
265
|
+
expect(isSharedIpThrottleOnlyBlockReason('otp_verify_failed')).toBe(true);
|
|
266
|
+
expect(isSharedIpThrottleOnlyBlockReason('mfa_verify_failed')).toBe(true);
|
|
267
|
+
expect(isSharedIpThrottleOnlyBlockReason('reauth_verify_failed')).toBe(
|
|
268
|
+
true
|
|
269
|
+
);
|
|
270
|
+
expect(isSharedIpThrottleOnlyBlockReason('password_login_failed')).toBe(
|
|
271
|
+
true
|
|
272
|
+
);
|
|
273
|
+
expect(isSharedIpThrottleOnlyBlockReason('api_auth_failed')).toBe(false);
|
|
274
|
+
expect(isSharedIpThrottleOnlyBlockReason('api_abuse')).toBe(false);
|
|
275
|
+
expect(isSharedIpThrottleOnlyBlockReason('manual')).toBe(false);
|
|
276
|
+
});
|
|
277
|
+
});
|
|
278
|
+
|
|
254
279
|
describe('password login failure limits', () => {
|
|
255
280
|
it('keeps the IP-wide failure counter after another account succeeds', async () => {
|
|
256
281
|
const ipAddress = '203.0.113.41';
|
|
@@ -293,6 +318,33 @@ describe('abuse-protection', () => {
|
|
|
293
318
|
remainingAttempts: 10,
|
|
294
319
|
});
|
|
295
320
|
});
|
|
321
|
+
|
|
322
|
+
it('throttles shared-IP password failures without writing a hard IP block', async () => {
|
|
323
|
+
const ipAddress = '203.0.113.42';
|
|
324
|
+
|
|
325
|
+
for (
|
|
326
|
+
let attempt = 0;
|
|
327
|
+
attempt < ABUSE_THRESHOLDS.PASSWORD_LOGIN_FAILED_MAX;
|
|
328
|
+
attempt += 1
|
|
329
|
+
) {
|
|
330
|
+
await recordPasswordLoginFailure(
|
|
331
|
+
ipAddress,
|
|
332
|
+
`password-shared-ip-${attempt}@example.com`
|
|
333
|
+
);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
const result = await checkPasswordLoginLimit(
|
|
337
|
+
ipAddress,
|
|
338
|
+
'another-user@example.com'
|
|
339
|
+
);
|
|
340
|
+
|
|
341
|
+
expect(result).toMatchObject({
|
|
342
|
+
allowed: false,
|
|
343
|
+
reason: 'Too many failed login attempts',
|
|
344
|
+
remainingAttempts: 0,
|
|
345
|
+
});
|
|
346
|
+
expect(result.blocked).not.toBe(true);
|
|
347
|
+
});
|
|
296
348
|
});
|
|
297
349
|
|
|
298
350
|
describe('BLOCK_DURATIONS constants', () => {
|
|
@@ -469,6 +521,90 @@ describe('abuse-protection', () => {
|
|
|
469
521
|
expect(blockedAttempt.retryAfter).toBeGreaterThan(0);
|
|
470
522
|
});
|
|
471
523
|
|
|
524
|
+
it('throttles aggressive shared-IP OTP sends without writing a hard IP block', async () => {
|
|
525
|
+
vi.stubEnv('ABUSE_OTP_SEND_IP_LIMIT_MINUTE', '1');
|
|
526
|
+
const ipAddress = '198.51.100.12';
|
|
527
|
+
|
|
528
|
+
await expect(
|
|
529
|
+
checkOTPSendAllowed(ipAddress, 'otp-shared-ip-1@example.com')
|
|
530
|
+
).resolves.toMatchObject({ allowed: true });
|
|
531
|
+
|
|
532
|
+
const blockedAttempt = await checkOTPSendAllowed(
|
|
533
|
+
ipAddress,
|
|
534
|
+
'otp-shared-ip-2@example.com'
|
|
535
|
+
);
|
|
536
|
+
|
|
537
|
+
expect(blockedAttempt).toMatchObject({
|
|
538
|
+
allowed: false,
|
|
539
|
+
remainingAttempts: 0,
|
|
540
|
+
});
|
|
541
|
+
expect(blockedAttempt.blocked).not.toBe(true);
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
it('throttles shared-IP OTP verify failures without writing a hard IP block', async () => {
|
|
545
|
+
const ipAddress = '198.51.100.13';
|
|
546
|
+
|
|
547
|
+
for (
|
|
548
|
+
let attempt = 0;
|
|
549
|
+
attempt < ABUSE_THRESHOLDS.OTP_VERIFY_FAILED_MAX;
|
|
550
|
+
attempt += 1
|
|
551
|
+
) {
|
|
552
|
+
await recordOTPVerifyFailure(
|
|
553
|
+
ipAddress,
|
|
554
|
+
`otp-verify-shared-ip-${attempt}@example.com`
|
|
555
|
+
);
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
const result = await checkOTPVerifyLimit(
|
|
559
|
+
ipAddress,
|
|
560
|
+
'legit-teacher@example.com'
|
|
561
|
+
);
|
|
562
|
+
|
|
563
|
+
expect(result).toMatchObject({
|
|
564
|
+
allowed: false,
|
|
565
|
+
reason: 'Too many failed verification attempts from this IP',
|
|
566
|
+
remainingAttempts: 0,
|
|
567
|
+
});
|
|
568
|
+
expect(result.blocked).not.toBe(true);
|
|
569
|
+
});
|
|
570
|
+
|
|
571
|
+
it('throttles shared-IP MFA and reauth failures without writing a hard IP block', async () => {
|
|
572
|
+
const mfaIpAddress = '198.51.100.14';
|
|
573
|
+
const reauthIpAddress = '198.51.100.15';
|
|
574
|
+
|
|
575
|
+
for (
|
|
576
|
+
let attempt = 0;
|
|
577
|
+
attempt < ABUSE_THRESHOLDS.MFA_VERIFY_FAILED_MAX;
|
|
578
|
+
attempt += 1
|
|
579
|
+
) {
|
|
580
|
+
await recordMFAVerifyFailure(mfaIpAddress);
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
for (
|
|
584
|
+
let attempt = 0;
|
|
585
|
+
attempt < ABUSE_THRESHOLDS.REAUTH_VERIFY_FAILED_MAX;
|
|
586
|
+
attempt += 1
|
|
587
|
+
) {
|
|
588
|
+
await recordReauthVerifyFailure(reauthIpAddress);
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
const mfaResult = await checkMFAVerifyLimit(mfaIpAddress);
|
|
592
|
+
const reauthResult = await checkReauthVerifyLimit(reauthIpAddress);
|
|
593
|
+
|
|
594
|
+
expect(mfaResult).toMatchObject({
|
|
595
|
+
allowed: false,
|
|
596
|
+
reason: 'Too many failed MFA verification attempts',
|
|
597
|
+
remainingAttempts: 0,
|
|
598
|
+
});
|
|
599
|
+
expect(reauthResult).toMatchObject({
|
|
600
|
+
allowed: false,
|
|
601
|
+
reason: 'Too many failed reauthentication attempts',
|
|
602
|
+
remainingAttempts: 0,
|
|
603
|
+
});
|
|
604
|
+
expect(mfaResult.blocked).not.toBe(true);
|
|
605
|
+
expect(reauthResult.blocked).not.toBe(true);
|
|
606
|
+
});
|
|
607
|
+
|
|
472
608
|
it('caps successful sends for the same email across distributed IPs within an hour', async () => {
|
|
473
609
|
vi.useFakeTimers();
|
|
474
610
|
vi.setSystemTime(new Date('2026-03-12T00:00:00.000Z'));
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { blockIPEdge } from './edge';
|
|
2
1
|
import type { BlockInfo } from './types';
|
|
3
2
|
|
|
4
3
|
type BackendRateLimitSource = 'auth' | 'database';
|
|
@@ -37,8 +36,10 @@ export function isBackendRateLimitError(
|
|
|
37
36
|
}
|
|
38
37
|
|
|
39
38
|
export async function cascadeBackendRateLimitToProxyBan({
|
|
40
|
-
ipAddress,
|
|
39
|
+
ipAddress: _ipAddress,
|
|
41
40
|
}: BackendRateLimitEscalationOptions): Promise<BlockInfo | null> {
|
|
42
|
-
|
|
43
|
-
|
|
41
|
+
// A backend 429 is an availability/backpressure signal. It is not enough
|
|
42
|
+
// evidence to hard-ban a public NAT IP that may represent a classroom,
|
|
43
|
+
// university, or enterprise office.
|
|
44
|
+
return null;
|
|
44
45
|
}
|
|
@@ -39,6 +39,8 @@ export interface CachedTrustEntry {
|
|
|
39
39
|
mode?: 'absolute' | 'unlimited';
|
|
40
40
|
/** For mode=absolute: explicit per-window READ limits. */
|
|
41
41
|
abs?: { minute?: number; hour?: number; day?: number };
|
|
42
|
+
/** Server-verified session marker. Does not increase limits by itself. */
|
|
43
|
+
verified?: boolean;
|
|
42
44
|
}
|
|
43
45
|
|
|
44
46
|
const DEFAULT_TRUST_CACHE_TTL_SECONDS = 3600; // 1 hour
|
|
@@ -156,14 +158,25 @@ function parseEntry(value: unknown): CachedTrustEntry | null {
|
|
|
156
158
|
? record.mode
|
|
157
159
|
: undefined;
|
|
158
160
|
const abs = parseAbsLimits(record.abs);
|
|
161
|
+
const verified = record.verified === true;
|
|
159
162
|
|
|
160
|
-
|
|
163
|
+
const entry: CachedTrustEntry = { m };
|
|
164
|
+
if (mode) {
|
|
165
|
+
entry.mode = mode;
|
|
166
|
+
}
|
|
167
|
+
if (abs) {
|
|
168
|
+
entry.abs = abs;
|
|
169
|
+
}
|
|
170
|
+
if (verified) {
|
|
171
|
+
entry.verified = true;
|
|
172
|
+
}
|
|
173
|
+
return entry;
|
|
161
174
|
}
|
|
162
175
|
|
|
163
176
|
/** Serializes an entry for storage: plain number when multiplier-only. */
|
|
164
177
|
function serializeEntry(entry: CachedTrustEntry): number | CachedTrustEntry {
|
|
165
178
|
const m = clampMultiplier(entry.m);
|
|
166
|
-
if (!entry.mode && !entry.abs) {
|
|
179
|
+
if (!entry.mode && !entry.abs && !entry.verified) {
|
|
167
180
|
return m;
|
|
168
181
|
}
|
|
169
182
|
|
|
@@ -182,20 +195,28 @@ function serializeEntry(entry: CachedTrustEntry): number | CachedTrustEntry {
|
|
|
182
195
|
serialized.abs = abs;
|
|
183
196
|
}
|
|
184
197
|
}
|
|
198
|
+
if (entry.verified) {
|
|
199
|
+
serialized.verified = true;
|
|
200
|
+
}
|
|
185
201
|
return serialized;
|
|
186
202
|
}
|
|
187
203
|
|
|
188
204
|
function isActiveEntry(entry: CachedTrustEntry): boolean {
|
|
189
|
-
return
|
|
205
|
+
return (
|
|
206
|
+
entry.mode != null ||
|
|
207
|
+
entry.verified === true ||
|
|
208
|
+
entry.m > NEUTRAL_TRUST_MULTIPLIER
|
|
209
|
+
);
|
|
190
210
|
}
|
|
191
211
|
|
|
192
212
|
/**
|
|
193
213
|
* Resolves the cached trust multiplier for each provided subject key.
|
|
194
214
|
*
|
|
195
215
|
* Returns a map of subject key -> clamped multiplier for keys that have a cache
|
|
196
|
-
* entry above the neutral baseline
|
|
197
|
-
*
|
|
198
|
-
* (
|
|
216
|
+
* entry above the neutral baseline, admin-mode entries, or neutral verified
|
|
217
|
+
* session entries. Missing keys are omitted. Returns an empty map on any Redis
|
|
218
|
+
* error (fail-open). Lets callers distinguish account trust (session key) from
|
|
219
|
+
* location trust (cidr/ip keys).
|
|
199
220
|
*/
|
|
200
221
|
export async function getCachedTrustEntries(
|
|
201
222
|
subjectKeys: string[]
|
|
@@ -342,3 +363,50 @@ export async function writeTrustCacheForSubjects(
|
|
|
342
363
|
// Cache writes must never block the protected request path.
|
|
343
364
|
}
|
|
344
365
|
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Write-through a neutral marker for server-verified browser/app sessions.
|
|
369
|
+
* This only proves the session cookie was successfully validated downstream; it
|
|
370
|
+
* does not grant a read-limit multiplier or admin override.
|
|
371
|
+
*/
|
|
372
|
+
export async function writeVerifiedSessionCacheForSubjects(
|
|
373
|
+
subjectKeys: string[],
|
|
374
|
+
ttlSeconds: number = getTrustCacheTtlSeconds()
|
|
375
|
+
): Promise<void> {
|
|
376
|
+
const sessionKeys = subjectKeys.filter((subjectKey) =>
|
|
377
|
+
subjectKey.startsWith('session:')
|
|
378
|
+
);
|
|
379
|
+
if (sessionKeys.length === 0) {
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
try {
|
|
384
|
+
const redis = await getTrustRedisClient();
|
|
385
|
+
if (!redis) {
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
const existingEntries = await getCachedTrustEntries(sessionKeys);
|
|
390
|
+
const verifiedEntry = serializeEntry({
|
|
391
|
+
m: NEUTRAL_TRUST_MULTIPLIER,
|
|
392
|
+
verified: true,
|
|
393
|
+
});
|
|
394
|
+
await Promise.all(
|
|
395
|
+
sessionKeys.map((subjectKey) => {
|
|
396
|
+
const existing = existingEntries.get(subjectKey);
|
|
397
|
+
if (
|
|
398
|
+
existing &&
|
|
399
|
+
(existing.mode != null || existing.abs != null || existing.m > 1)
|
|
400
|
+
) {
|
|
401
|
+
return Promise.resolve();
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
return redis.set(buildTrustCacheKey(subjectKey), verifiedEntry, {
|
|
405
|
+
ex: ttlSeconds,
|
|
406
|
+
});
|
|
407
|
+
})
|
|
408
|
+
);
|
|
409
|
+
} catch {
|
|
410
|
+
// Cache writes must never block the protected request path.
|
|
411
|
+
}
|
|
412
|
+
}
|
|
@@ -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
|
/**
|