@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
|
@@ -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('
|
|
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
|
});
|
|
@@ -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('
|
|
354
|
-
const email = `
|
|
456
|
+
it('reserves same-email cooldown before provider success', async () => {
|
|
457
|
+
const email = `reserved-cooldown-${Date.now()}@example.com`;
|
|
355
458
|
|
|
356
|
-
const
|
|
357
|
-
|
|
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(
|
|
360
|
-
expect(
|
|
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
|
-
|
|
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
|
}
|