@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.
@@ -110,11 +110,31 @@ describe('resolveInternalAppUrl', () => {
110
110
  kind: 'internal',
111
111
  name: 'tasks',
112
112
  });
113
+ expect(
114
+ getAppDomainByUrl(
115
+ 'https://zalo-qr-chat-setup.tasks.tuturuuu.localhost:1355/verify-token?nextUrl=%2Fpersonal'
116
+ )
117
+ ).toMatchObject({
118
+ canonicalUrl:
119
+ 'https://zalo-qr-chat-setup.tasks.tuturuuu.localhost/verify-token?nextUrl=%2Fpersonal',
120
+ kind: 'internal',
121
+ name: 'tasks',
122
+ });
113
123
  expect(
114
124
  getAppDomainByUrl(
115
125
  'https://tuturuuu.localhost.evil.test:1355/verify-token?nextUrl=%2F'
116
126
  )
117
127
  ).toBeNull();
128
+ expect(
129
+ getAppDomainByUrl(
130
+ 'https://tasks.tuturuuu.localhost:4444/verify-token?nextUrl=%2Fpersonal'
131
+ )
132
+ ).toBeNull();
133
+ expect(
134
+ getAppDomainByUrl(
135
+ 'https://attacker.tasks.tuturuuu.localhost:4444/verify-token?nextUrl=%2Fpersonal'
136
+ )
137
+ ).toBeNull();
118
138
  });
119
139
 
120
140
  it('canonicalizes production internal app URLs to their registered HTTPS origins', () => {
@@ -244,6 +264,7 @@ describe('Portless app origin registry', () => {
244
264
  'https://finance.tuturuuu.localhost',
245
265
  'https://hive.tuturuuu.localhost',
246
266
  'https://realtime.hive.tuturuuu.localhost',
267
+ 'https://infra.tuturuuu.localhost',
247
268
  'https://inventory.tuturuuu.localhost',
248
269
  'https://learn.tuturuuu.localhost',
249
270
  'https://mail.tuturuuu.localhost',
@@ -256,6 +277,7 @@ describe('Portless app origin registry', () => {
256
277
  'https://rewise.tuturuuu.localhost',
257
278
  'https://shortener.tuturuuu.localhost',
258
279
  'https://storefront.tuturuuu.localhost',
280
+ 'https://tanstack.tuturuuu.localhost',
259
281
  'https://tasks.tuturuuu.localhost',
260
282
  'https://teach.tuturuuu.localhost',
261
283
  'https://track.tuturuuu.localhost',
@@ -30,12 +30,15 @@ describe('upstash-rest', () => {
30
30
  vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.test');
31
31
  vi.stubEnv('UPSTASH_REDIS_REST_TOKEN', 'token');
32
32
  const client = {
33
+ decr: vi.fn(),
33
34
  del: vi.fn(),
34
35
  eval: vi.fn(),
35
36
  evalsha: vi.fn(),
36
37
  expire: vi.fn(),
37
38
  get: vi.fn(),
38
39
  incr: vi.fn(),
40
+ mget: vi.fn(),
41
+ scan: vi.fn(),
39
42
  set: vi.fn(),
40
43
  ttl: vi.fn(),
41
44
  };
@@ -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('blocks the IP without suspending an identified user', async () => {
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).toBe(blockInfo);
72
- expect(mocks.blockIPEdge).toHaveBeenCalledWith(
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 inspect existing user suspensions', async () => {
81
- mocks.checkUserSuspension.mockResolvedValue({
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(mocks.suspendUser).not.toHaveBeenCalled();
94
- expect(mocks.blockIPEdge).toHaveBeenCalledWith(
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('does not suspend the user when the client IP is unavailable', async () => {
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
  });
@@ -0,0 +1,258 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
+
3
+ const mocks = vi.hoisted(() => ({
4
+ getClient: vi.fn(),
5
+ mget: vi.fn(),
6
+ set: vi.fn(),
7
+ }));
8
+
9
+ vi.mock('../../upstash-rest', () => ({
10
+ getUpstashRestRedisClient: () => mocks.getClient(),
11
+ }));
12
+
13
+ function mockRedisClient() {
14
+ mocks.getClient.mockResolvedValue({
15
+ mget: (...keys: string[]) => mocks.mget(...keys),
16
+ set: (key: string, value: unknown, options?: unknown) =>
17
+ mocks.set(key, value, options),
18
+ });
19
+ }
20
+
21
+ describe('edge-trust cache', () => {
22
+ beforeEach(() => {
23
+ vi.resetModules();
24
+ mocks.getClient.mockReset();
25
+ mocks.mget.mockReset();
26
+ mocks.set.mockReset();
27
+ });
28
+
29
+ afterEach(() => {
30
+ vi.unstubAllEnvs();
31
+ });
32
+
33
+ it('returns only elevated subjects from getCachedTrustMultipliers', async () => {
34
+ mockRedisClient();
35
+ // Order of values matches the order of keys requested.
36
+ mocks.mget.mockResolvedValue([3, null, 1, 2]);
37
+
38
+ const { getCachedTrustMultipliers } = await import('../edge-trust.js');
39
+ const result = await getCachedTrustMultipliers([
40
+ 'session:aaa',
41
+ 'cidr:1.2.3.0/24',
42
+ 'ip:1.2.3.4',
43
+ 'user:abc',
44
+ ]);
45
+
46
+ expect(result.get('session:aaa')).toBe(3);
47
+ expect(result.has('cidr:1.2.3.0/24')).toBe(false); // null
48
+ expect(result.has('ip:1.2.3.4')).toBe(false); // neutral (1)
49
+ expect(result.get('user:abc')).toBe(2);
50
+ });
51
+
52
+ it('clamps cached multipliers to the DB ceiling', async () => {
53
+ mockRedisClient();
54
+ mocks.mget.mockResolvedValue([5000]);
55
+
56
+ const { getCachedTrustMultipliers } = await import('../edge-trust.js');
57
+ const result = await getCachedTrustMultipliers(['session:aaa']);
58
+
59
+ expect(result.get('session:aaa')).toBe(1000);
60
+ });
61
+
62
+ it('takes the most-trusting multiplier in getCachedTrustMultiplier', async () => {
63
+ mockRedisClient();
64
+ mocks.mget.mockResolvedValue([2, 3]);
65
+
66
+ const { getCachedTrustMultiplier } = await import('../edge-trust.js');
67
+ const best = await getCachedTrustMultiplier([
68
+ 'cidr:1.2.3.0/24',
69
+ 'session:aaa',
70
+ ]);
71
+
72
+ expect(best).toBe(3);
73
+ });
74
+
75
+ it('parses string multipliers from Redis', async () => {
76
+ mockRedisClient();
77
+ mocks.mget.mockResolvedValue(['2.5']);
78
+
79
+ const { getCachedTrustMultiplier } = await import('../edge-trust.js');
80
+ expect(await getCachedTrustMultiplier(['session:aaa'])).toBe(2.5);
81
+ });
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
+
94
+ it('fails open to a neutral multiplier when Redis errors', async () => {
95
+ mockRedisClient();
96
+ mocks.mget.mockRejectedValue(new Error('redis down'));
97
+
98
+ const { getCachedTrustMultiplier, getCachedTrustMultipliers } =
99
+ await import('../edge-trust.js');
100
+
101
+ expect(await getCachedTrustMultiplier(['session:aaa'])).toBe(1);
102
+ expect((await getCachedTrustMultipliers(['session:aaa'])).size).toBe(0);
103
+ });
104
+
105
+ it('fails open when no Redis client is configured', async () => {
106
+ mocks.getClient.mockResolvedValue(null);
107
+
108
+ const { getCachedTrustMultiplier } = await import('../edge-trust.js');
109
+ expect(await getCachedTrustMultiplier(['session:aaa'])).toBe(1);
110
+ });
111
+
112
+ it('writes a clamped multiplier with a TTL', async () => {
113
+ mockRedisClient();
114
+ mocks.set.mockResolvedValue('OK');
115
+
116
+ const { setCachedTrustMultiplier } = await import('../edge-trust.js');
117
+ await setCachedTrustMultiplier('cidr:1.2.3.0/24', 5000, 7200);
118
+
119
+ expect(mocks.set).toHaveBeenCalledWith('trust:mult:cidr:1.2.3.0/24', 1000, {
120
+ ex: 7200,
121
+ });
122
+ });
123
+
124
+ it('skips neutral multipliers in writeTrustCacheForSubjects', async () => {
125
+ mockRedisClient();
126
+ mocks.set.mockResolvedValue('OK');
127
+
128
+ const { writeTrustCacheForSubjects } = await import('../edge-trust.js');
129
+ await writeTrustCacheForSubjects(['session:aaa', 'cidr:1.2.3.0/24'], 1);
130
+
131
+ expect(mocks.set).not.toHaveBeenCalled();
132
+ });
133
+
134
+ it('writes every subject key for an elevated multiplier', async () => {
135
+ mockRedisClient();
136
+ mocks.set.mockResolvedValue('OK');
137
+
138
+ const { writeTrustCacheForSubjects } = await import('../edge-trust.js');
139
+ await writeTrustCacheForSubjects(
140
+ ['session:aaa', 'cidr:1.2.3.0/24'],
141
+ 3,
142
+ 600
143
+ );
144
+
145
+ expect(mocks.set).toHaveBeenCalledWith('trust:mult:session:aaa', 3, {
146
+ ex: 600,
147
+ });
148
+ expect(mocks.set).toHaveBeenCalledWith('trust:mult:cidr:1.2.3.0/24', 3, {
149
+ ex: 600,
150
+ });
151
+ });
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
+
200
+ it('parses legacy plain numbers and rich JSON entries together', async () => {
201
+ mockRedisClient();
202
+ mocks.mget.mockResolvedValue([
203
+ 3, // legacy plain number
204
+ { m: 1, mode: 'unlimited' }, // rich object (auto-deserialized by upstash)
205
+ { m: 1, mode: 'absolute', abs: { minute: 500 } },
206
+ ]);
207
+
208
+ const { getCachedTrustEntries } = await import('../edge-trust.js');
209
+ const entries = await getCachedTrustEntries([
210
+ 'session:legacy',
211
+ 'cidr:unl',
212
+ 'ip:abs',
213
+ ]);
214
+
215
+ expect(entries.get('session:legacy')).toEqual({ m: 3 });
216
+ expect(entries.get('cidr:unl')).toEqual({ m: 1, mode: 'unlimited' });
217
+ expect(entries.get('ip:abs')).toEqual({
218
+ abs: { minute: 500 },
219
+ m: 1,
220
+ mode: 'absolute',
221
+ });
222
+ });
223
+
224
+ it('writes rich entries as JSON and multiplier-only as a plain number', async () => {
225
+ mockRedisClient();
226
+ mocks.set.mockResolvedValue('OK');
227
+
228
+ const { setCachedTrustEntry } = await import('../edge-trust.js');
229
+ await setCachedTrustEntry('session:a', { m: 3 }, 600);
230
+ await setCachedTrustEntry(
231
+ 'cidr:b',
232
+ { abs: { minute: 500 }, m: 1, mode: 'absolute' },
233
+ 600
234
+ );
235
+
236
+ expect(mocks.set).toHaveBeenCalledWith('trust:mult:session:a', 3, {
237
+ ex: 600,
238
+ });
239
+ expect(mocks.set).toHaveBeenCalledWith(
240
+ 'trust:mult:cidr:b',
241
+ { abs: { minute: 500 }, m: 1, mode: 'absolute' },
242
+ { ex: 600 }
243
+ );
244
+ });
245
+
246
+ it('honors the configurable cache TTL', async () => {
247
+ vi.stubEnv('EDGE_TRUST_CACHE_TTL_SECONDS', '900');
248
+ mockRedisClient();
249
+ mocks.set.mockResolvedValue('OK');
250
+
251
+ const { setCachedTrustMultiplier } = await import('../edge-trust.js');
252
+ await setCachedTrustMultiplier('session:aaa', 3);
253
+
254
+ expect(mocks.set).toHaveBeenCalledWith('trust:mult:session:aaa', 3, {
255
+ ex: 900,
256
+ });
257
+ });
258
+ });
@@ -3,16 +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,
13
+ checkPasswordLoginLimit,
14
+ checkReauthVerifyLimit,
10
15
  classifyPotentialSpamUserAgent,
16
+ clearPasswordLoginFailures,
11
17
  extractIPFromHeaders,
12
18
  hashEmail,
19
+ isSharedIpThrottleOnlyBlockReason,
13
20
  MAX_BLOCK_LEVEL,
14
21
  REDIS_KEYS,
22
+ recordMFAVerifyFailure,
15
23
  recordOTPSendSuccess,
24
+ recordOTPVerifyFailure,
25
+ recordPasswordLoginFailure,
26
+ recordReauthVerifyFailure,
16
27
  resetOtpLimitsForEmail,
17
28
  WINDOW_MS,
18
29
  } from '../index';
@@ -241,6 +252,98 @@ describe('abuse-protection', () => {
241
252
  5 * 60 * 1000
242
253
  );
243
254
  expect(ABUSE_THRESHOLDS.PASSWORD_LOGIN_FAILED_MAX).toBe(10);
255
+ expect(ABUSE_THRESHOLDS.PASSWORD_LOGIN_FAILED_EMAIL_WINDOW_MS).toBe(
256
+ 15 * 60 * 1000
257
+ );
258
+ expect(ABUSE_THRESHOLDS.PASSWORD_LOGIN_FAILED_EMAIL_MAX).toBe(10);
259
+ });
260
+ });
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
+
279
+ describe('password login failure limits', () => {
280
+ it('keeps the IP-wide failure counter after another account succeeds', async () => {
281
+ const ipAddress = '203.0.113.41';
282
+
283
+ for (let attempt = 0; attempt < 9; attempt += 1) {
284
+ await recordPasswordLoginFailure(ipAddress, 'victim@example.com');
285
+ }
286
+
287
+ await clearPasswordLoginFailures(ipAddress, 'attacker@example.com');
288
+
289
+ await expect(
290
+ checkPasswordLoginLimit(ipAddress, 'victim@example.com')
291
+ ).resolves.toMatchObject({
292
+ allowed: true,
293
+ remainingAttempts: 1,
294
+ });
295
+ });
296
+
297
+ it('clears only the successful account email failure counter', async () => {
298
+ const email = 'targeted-user@example.com';
299
+
300
+ for (let attempt = 0; attempt < 10; attempt += 1) {
301
+ await recordPasswordLoginFailure(`198.51.100.${attempt + 1}`, email);
302
+ }
303
+
304
+ await expect(
305
+ checkPasswordLoginLimit('198.51.100.200', email)
306
+ ).resolves.toMatchObject({
307
+ allowed: false,
308
+ reason: 'Too many failed login attempts for this email',
309
+ remainingAttempts: 0,
310
+ });
311
+
312
+ await clearPasswordLoginFailures('198.51.100.200', email);
313
+
314
+ await expect(
315
+ checkPasswordLoginLimit('198.51.100.200', email)
316
+ ).resolves.toMatchObject({
317
+ allowed: true,
318
+ remainingAttempts: 10,
319
+ });
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);
244
347
  });
245
348
  });
246
349
 
@@ -350,14 +453,19 @@ describe('abuse-protection', () => {
350
453
  });
351
454
 
352
455
  describe('checkOTPSendAllowed', () => {
353
- it('does not consume quota during preflight checks', async () => {
354
- const email = `preflight-${Date.now()}@example.com`;
456
+ it('reserves same-email cooldown before provider success', async () => {
457
+ const email = `reserved-cooldown-${Date.now()}@example.com`;
355
458
 
356
- const firstAttempt = await checkOTPSendAllowed('198.51.100.1', email);
357
- const secondAttempt = await checkOTPSendAllowed('198.51.100.2', email);
459
+ const attempts = await Promise.all([
460
+ checkOTPSendAllowed('198.51.100.1', email),
461
+ checkOTPSendAllowed('198.51.100.2', email),
462
+ ]);
463
+ const allowedAttempts = attempts.filter((attempt) => attempt.allowed);
464
+ const blockedAttempts = attempts.filter((attempt) => !attempt.allowed);
358
465
 
359
- expect(firstAttempt.allowed).toBe(true);
360
- expect(secondAttempt.allowed).toBe(true);
466
+ expect(allowedAttempts).toHaveLength(1);
467
+ expect(blockedAttempts).toHaveLength(1);
468
+ expect(blockedAttempts[0]?.retryAfter).toBeGreaterThan(0);
361
469
  });
362
470
 
363
471
  it('blocks repeated sends to the same email across different IPs during cooldown', async () => {
@@ -413,6 +521,90 @@ describe('abuse-protection', () => {
413
521
  expect(blockedAttempt.retryAfter).toBeGreaterThan(0);
414
522
  });
415
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
+
416
608
  it('caps successful sends for the same email across distributed IPs within an hour', async () => {
417
609
  vi.useFakeTimers();
418
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
- const shouldBlockIp = ipAddress && ipAddress !== 'unknown';
43
- return shouldBlockIp ? blockIPEdge(ipAddress, 'api_abuse') : null;
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
  }