@tuturuuu/utils 0.7.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +28 -0
- package/package.json +5 -4
- package/src/__tests__/api-proxy-guard.test.ts +419 -12
- package/src/__tests__/app-url.test.ts +20 -0
- package/src/__tests__/upstash-rest.test.ts +3 -0
- package/src/abuse-protection/__tests__/edge-trust.test.ts +200 -0
- package/src/abuse-protection/__tests__/index.test.ts +64 -8
- package/src/abuse-protection/constants.ts +4 -0
- package/src/abuse-protection/edge-trust.ts +344 -0
- package/src/abuse-protection/index.ts +85 -98
- package/src/abuse-protection/reputation.ts +10 -0
- package/src/api-proxy-guard.ts +347 -24
- package/src/internal-domains.ts +54 -2
- package/src/money.test.ts +82 -0
- package/src/money.ts +120 -0
- package/src/next-config.test.ts +13 -0
- package/src/next-config.ts +0 -8
- package/src/platform-release.test.ts +1 -1
- package/src/platform-release.ts +1 -1
- package/src/upstash-rest.ts +3 -1
- package/src/workspace-helper.ts +28 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,33 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.8.0](https://github.com/tutur3u/platform/compare/utils-v0.7.0...utils-v0.8.0) (2026-06-17)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Features
|
|
7
|
+
|
|
8
|
+
* **inventory:** store commerce money in minor units and harden Polar sync ([3f7ee1d](https://github.com/tutur3u/platform/commit/3f7ee1da9335732854037e7e79fca9d5d2a381d0))
|
|
9
|
+
* **web:** rate limits admin center ([fe61dc9](https://github.com/tutur3u/platform/commit/fe61dc933615466f6169a3db3a4a1840f705e80b))
|
|
10
|
+
* **web:** trust-gated per-session read limits and trusted-location uplift ([3e64c89](https://github.com/tutur3u/platform/commit/3e64c8929dbe7987ad83900620a25a9559fe1edf))
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
### Bug Fixes
|
|
14
|
+
|
|
15
|
+
* **api:** share broad anonymous rate buckets ([04c1dc6](https://github.com/tutur3u/platform/commit/04c1dc616bf6a909b37f798c994aba41ff27d13a))
|
|
16
|
+
* **api:** throttle users database post reads ([698639d](https://github.com/tutur3u/platform/commit/698639d572375a67b850d533e584aa05733dc961))
|
|
17
|
+
* **auth:** preserve password failure pressure ([cf1e970](https://github.com/tutur3u/platform/commit/cf1e970a0f8acbf314cbdccd7abc126535dbd846))
|
|
18
|
+
* **auth:** reserve otp send rate limits ([42012b9](https://github.com/tutur3u/platform/commit/42012b90c75f1041637b1d3ea21ff464c942c6d9))
|
|
19
|
+
* **auth:** restrict Portless return ports ([7977bfc](https://github.com/tutur3u/platform/commit/7977bfc074a211a20b3bf133fe78cf94ff8c1d33))
|
|
20
|
+
* **ci:** format storefront files and harden upstash scan wrapper ([4c82444](https://github.com/tutur3u/platform/commit/4c824446725dc280edde3e54f946bf386a52952d))
|
|
21
|
+
* **ci:** unblock turbopack proxy build, type-safe upstash, format ([42393f8](https://github.com/tutur3u/platform/commit/42393f8519f6695df44f71b8654ba86237296ffe))
|
|
22
|
+
* **email:** keep override traffic in proxy guard ([dd6ed5a](https://github.com/tutur3u/platform/commit/dd6ed5a9a21f536e22f7abbbeb3e83f44a81cdff))
|
|
23
|
+
* **next:** remove loopback image defaults ([9ab8fb9](https://github.com/tutur3u/platform/commit/9ab8fb9489a613e72302d5baf35e44f8cea5c82a))
|
|
24
|
+
* **web:** stop new-workspace setup from trapping users on "Preparing Workspace" ([3f7ee1d](https://github.com/tutur3u/platform/commit/3f7ee1da9335732854037e7e79fca9d5d2a381d0))
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
### Performance Improvements
|
|
28
|
+
|
|
29
|
+
* **web:** hydrate user-group detail pages and remove fetch waterfalls ([4845cf7](https://github.com/tutur3u/platform/commit/4845cf71cc7e3e321bd6558ba4e20a406e9cd731))
|
|
30
|
+
|
|
3
31
|
## [0.7.0](https://github.com/tutur3u/platform/compare/utils-v0.6.1...utils-v0.7.0) (2026-06-15)
|
|
4
32
|
|
|
5
33
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tuturuuu/utils",
|
|
3
3
|
"license": "MIT",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.8.0",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
7
7
|
"url": "https://github.com/tutur3u/platform",
|
|
@@ -24,8 +24,8 @@
|
|
|
24
24
|
"@tiptap/react": "3.26.1",
|
|
25
25
|
"@tuturuuu/google": "0.0.2",
|
|
26
26
|
"@tuturuuu/icons": "0.0.6",
|
|
27
|
-
"@tuturuuu/internal-api": "0.
|
|
28
|
-
"@tuturuuu/supabase": "0.3.
|
|
27
|
+
"@tuturuuu/internal-api": "0.9.0",
|
|
28
|
+
"@tuturuuu/supabase": "0.3.4",
|
|
29
29
|
"@upstash/ratelimit": "^2.0.8",
|
|
30
30
|
"@upstash/redis": "^1.38.0",
|
|
31
31
|
"clsx": "^2.1.1",
|
|
@@ -44,7 +44,7 @@
|
|
|
44
44
|
"zod": "^4.4.3"
|
|
45
45
|
},
|
|
46
46
|
"devDependencies": {
|
|
47
|
-
"@tuturuuu/types": "0.
|
|
47
|
+
"@tuturuuu/types": "0.10.0",
|
|
48
48
|
"@tuturuuu/typescript-config": "0.1.1",
|
|
49
49
|
"@types/diff": "^8.0.0",
|
|
50
50
|
"@types/node": "^25.9.3",
|
|
@@ -66,6 +66,7 @@
|
|
|
66
66
|
"./abuse-protection": "./src/abuse-protection/index.ts",
|
|
67
67
|
"./abuse-protection/backend-rate-limit": "./src/abuse-protection/backend-rate-limit.ts",
|
|
68
68
|
"./abuse-protection/edge": "./src/abuse-protection/edge.ts",
|
|
69
|
+
"./abuse-protection/edge-trust": "./src/abuse-protection/edge-trust.ts",
|
|
69
70
|
"./abuse-protection/user-suspension": "./src/abuse-protection/user-suspension.ts",
|
|
70
71
|
"./currencies": "./src/currencies.ts",
|
|
71
72
|
"./exchange-rates": "./src/exchange-rates.ts",
|
|
@@ -9,6 +9,7 @@ const mocks = vi.hoisted(() => ({
|
|
|
9
9
|
extractIp: vi.fn(),
|
|
10
10
|
isBlocked: vi.fn(),
|
|
11
11
|
validateEmoji: vi.fn(),
|
|
12
|
+
getTrustEntries: vi.fn(),
|
|
12
13
|
}));
|
|
13
14
|
|
|
14
15
|
vi.mock('@upstash/ratelimit', () => {
|
|
@@ -50,6 +51,10 @@ vi.mock('../abuse-protection/edge', () => ({
|
|
|
50
51
|
isIPBlockedEdge: (ip: string) => mocks.isBlocked(ip),
|
|
51
52
|
}));
|
|
52
53
|
|
|
54
|
+
vi.mock('../abuse-protection/edge-trust', () => ({
|
|
55
|
+
getCachedTrustEntries: (keys: string[]) => mocks.getTrustEntries(keys),
|
|
56
|
+
}));
|
|
57
|
+
|
|
53
58
|
vi.mock('../request-emoji-limit', () => ({
|
|
54
59
|
validateRequestEmojiLimit: (
|
|
55
60
|
req: NextRequest,
|
|
@@ -98,6 +103,9 @@ describe('guardApiProxyRequest', () => {
|
|
|
98
103
|
mocks.extractIp.mockReset();
|
|
99
104
|
mocks.isBlocked.mockReset();
|
|
100
105
|
mocks.validateEmoji.mockReset();
|
|
106
|
+
mocks.getTrustEntries.mockReset();
|
|
107
|
+
// Default: no cached trust (untrusted). Tests opt in by overriding.
|
|
108
|
+
mocks.getTrustEntries.mockResolvedValue(new Map<string, { m: number }>());
|
|
101
109
|
});
|
|
102
110
|
|
|
103
111
|
afterEach(() => {
|
|
@@ -204,7 +212,7 @@ describe('guardApiProxyRequest', () => {
|
|
|
204
212
|
mocks.isBlocked.mockResolvedValue(null);
|
|
205
213
|
mocks.limit.mockResolvedValueOnce({
|
|
206
214
|
success: false,
|
|
207
|
-
limit:
|
|
215
|
+
limit: 600,
|
|
208
216
|
remaining: 0,
|
|
209
217
|
reset: Date.now() + 15_000,
|
|
210
218
|
});
|
|
@@ -221,10 +229,10 @@ describe('guardApiProxyRequest', () => {
|
|
|
221
229
|
);
|
|
222
230
|
|
|
223
231
|
expect(response?.status).toBe(429);
|
|
224
|
-
expect(response?.headers.get('X-RateLimit-Limit')).toBe('
|
|
232
|
+
expect(response?.headers.get('X-RateLimit-Limit')).toBe('600');
|
|
225
233
|
expect(response?.headers.get('X-RateLimit-Policy')).toBe('task-board-read');
|
|
226
234
|
expect(mocks.ratelimitConfigs).toContainEqual({
|
|
227
|
-
limit:
|
|
235
|
+
limit: 600,
|
|
228
236
|
window: '1 m',
|
|
229
237
|
});
|
|
230
238
|
expect(mocks.ratelimitPrefixes).toContain(
|
|
@@ -658,7 +666,7 @@ describe('guardApiProxyRequest', () => {
|
|
|
658
666
|
}
|
|
659
667
|
|
|
660
668
|
expect(mocks.ratelimitPrefixes).toContain(
|
|
661
|
-
'proxy:test:api:default:anonymous
|
|
669
|
+
'proxy:test:api:default:anonymous:mutate:minute'
|
|
662
670
|
);
|
|
663
671
|
expect(mocks.ratelimitPrefixes).not.toContain(
|
|
664
672
|
'proxy:test:api:finance-invoice-create-read:anonymous:mutate:minute'
|
|
@@ -1015,7 +1023,7 @@ describe('guardApiProxyRequest', () => {
|
|
|
1015
1023
|
expect(mocks.limit).not.toHaveBeenCalled();
|
|
1016
1024
|
});
|
|
1017
1025
|
|
|
1018
|
-
it('
|
|
1026
|
+
it('shares broad anonymous read buckets across path variants', async () => {
|
|
1019
1027
|
vi.stubEnv('NODE_ENV', 'production');
|
|
1020
1028
|
vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.test');
|
|
1021
1029
|
vi.stubEnv('UPSTASH_REDIS_REST_TOKEN', 'token');
|
|
@@ -1036,13 +1044,28 @@ describe('guardApiProxyRequest', () => {
|
|
|
1036
1044
|
await guardApiProxyRequest(makeRequest('/api/v1/users/me/profile', 'GET'), {
|
|
1037
1045
|
prefixBase: 'proxy:test:api',
|
|
1038
1046
|
});
|
|
1047
|
+
await guardApiProxyRequest(
|
|
1048
|
+
makeRequest('/api/v1/users/me/nonexistent/random-nonce', 'GET'),
|
|
1049
|
+
{
|
|
1050
|
+
prefixBase: 'proxy:test:api',
|
|
1051
|
+
}
|
|
1052
|
+
);
|
|
1039
1053
|
|
|
1040
1054
|
expect(mocks.ratelimitPrefixes).toContain(
|
|
1055
|
+
'proxy:test:api:users-me:anonymous:get:minute'
|
|
1056
|
+
);
|
|
1057
|
+
expect(mocks.ratelimitPrefixes).not.toContain(
|
|
1041
1058
|
'proxy:test:api:users-me:anonymous::api:v1:users:me:profile:get:minute'
|
|
1042
1059
|
);
|
|
1060
|
+
expect(mocks.ratelimitPrefixes).not.toContain(
|
|
1061
|
+
'proxy:test:api:users-me:anonymous::api:v1:users:me:nonexistent:random-nonce:get:minute'
|
|
1062
|
+
);
|
|
1043
1063
|
});
|
|
1044
1064
|
|
|
1045
|
-
it(
|
|
1065
|
+
it.each([
|
|
1066
|
+
'/api/v1/auth/otp/send',
|
|
1067
|
+
'/api/v1/auth/mobile/send-otp',
|
|
1068
|
+
])('uses a classroom-friendly OTP send bucket for %s', async (path) => {
|
|
1046
1069
|
vi.stubEnv('NODE_ENV', 'production');
|
|
1047
1070
|
vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.test');
|
|
1048
1071
|
vi.stubEnv('UPSTASH_REDIS_REST_TOKEN', 'token');
|
|
@@ -1060,12 +1083,9 @@ describe('guardApiProxyRequest', () => {
|
|
|
1060
1083
|
await import('../api-proxy-guard.js');
|
|
1061
1084
|
clearApiProxyGuardLimiterCache();
|
|
1062
1085
|
|
|
1063
|
-
const response = await guardApiProxyRequest(
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
prefixBase: 'proxy:test:api',
|
|
1067
|
-
}
|
|
1068
|
-
);
|
|
1086
|
+
const response = await guardApiProxyRequest(makeRequest(path, 'POST'), {
|
|
1087
|
+
prefixBase: 'proxy:test:api',
|
|
1088
|
+
});
|
|
1069
1089
|
|
|
1070
1090
|
expect(response?.status).toBe(429);
|
|
1071
1091
|
expect(response?.headers.get('X-RateLimit-Limit')).toBe('30');
|
|
@@ -1275,6 +1295,59 @@ describe('guardApiProxyRequest', () => {
|
|
|
1275
1295
|
expect(response?.headers.get('X-RateLimit-Policy')).toBe('high-fanout');
|
|
1276
1296
|
});
|
|
1277
1297
|
|
|
1298
|
+
it('prepends additional route policies before default high-fanout buckets', async () => {
|
|
1299
|
+
vi.stubEnv('NODE_ENV', 'production');
|
|
1300
|
+
vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.test');
|
|
1301
|
+
vi.stubEnv('UPSTASH_REDIS_REST_TOKEN', 'token');
|
|
1302
|
+
mocks.redis.mockReturnValue({});
|
|
1303
|
+
mocks.extractIp.mockReturnValue('1.2.3.4');
|
|
1304
|
+
mocks.isBlocked.mockResolvedValue(null);
|
|
1305
|
+
mocks.limit.mockResolvedValueOnce({
|
|
1306
|
+
success: false,
|
|
1307
|
+
limit: 30,
|
|
1308
|
+
remaining: 0,
|
|
1309
|
+
reset: Date.now() + 15_000,
|
|
1310
|
+
});
|
|
1311
|
+
|
|
1312
|
+
const { guardApiProxyRequest, clearApiProxyGuardLimiterCache } =
|
|
1313
|
+
await import('../api-proxy-guard.js');
|
|
1314
|
+
clearApiProxyGuardLimiterCache();
|
|
1315
|
+
|
|
1316
|
+
const response = await guardApiProxyRequest(
|
|
1317
|
+
makeRequest('/api/v1/workspaces/ws-1/mail/send', 'POST'),
|
|
1318
|
+
{
|
|
1319
|
+
additionalRoutePolicies: [
|
|
1320
|
+
{
|
|
1321
|
+
key: 'email-rate-limit-override',
|
|
1322
|
+
matches: (req) =>
|
|
1323
|
+
/^\/api\/v1\/workspaces\/[^/]+\/mail\/send(?:\/|$)/.test(
|
|
1324
|
+
req.nextUrl.pathname
|
|
1325
|
+
),
|
|
1326
|
+
rateLimits: {
|
|
1327
|
+
get: [],
|
|
1328
|
+
mutate: [{ duration: '1 m', limit: 30, window: 'minute' }],
|
|
1329
|
+
},
|
|
1330
|
+
},
|
|
1331
|
+
],
|
|
1332
|
+
prefixBase: 'proxy:test:api',
|
|
1333
|
+
}
|
|
1334
|
+
);
|
|
1335
|
+
|
|
1336
|
+
expect(response?.status).toBe(429);
|
|
1337
|
+
expect(response?.headers.get('X-RateLimit-Limit')).toBe('30');
|
|
1338
|
+
expect(response?.headers.get('X-RateLimit-Policy')).toBe(
|
|
1339
|
+
'email-rate-limit-override'
|
|
1340
|
+
);
|
|
1341
|
+
expect(mocks.ratelimitConfigs).toContainEqual({
|
|
1342
|
+
limit: 30,
|
|
1343
|
+
window: '1 m',
|
|
1344
|
+
});
|
|
1345
|
+
expect(mocks.ratelimitConfigs).not.toContainEqual({
|
|
1346
|
+
limit: 2,
|
|
1347
|
+
window: '1 m',
|
|
1348
|
+
});
|
|
1349
|
+
});
|
|
1350
|
+
|
|
1278
1351
|
it('uses relaxed buckets for task description persistence routes', async () => {
|
|
1279
1352
|
vi.stubEnv('NODE_ENV', 'production');
|
|
1280
1353
|
vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.test');
|
|
@@ -1487,6 +1560,340 @@ describe('guardApiProxyRequest', () => {
|
|
|
1487
1560
|
expect(mocks.validateEmoji).toHaveBeenCalledTimes(1);
|
|
1488
1561
|
});
|
|
1489
1562
|
|
|
1563
|
+
it('gives a trusted session its own per-session bucket with scaled read limits', async () => {
|
|
1564
|
+
vi.stubEnv('NODE_ENV', 'production');
|
|
1565
|
+
vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.test');
|
|
1566
|
+
vi.stubEnv('UPSTASH_REDIS_REST_TOKEN', 'token');
|
|
1567
|
+
vi.stubEnv('NEXT_PUBLIC_SUPABASE_URL', '');
|
|
1568
|
+
mocks.redis.mockReturnValue({});
|
|
1569
|
+
mocks.extractIp.mockReturnValue('1.2.3.4');
|
|
1570
|
+
mocks.isBlocked.mockResolvedValue(null);
|
|
1571
|
+
// Trust the session subject key (account trust → per-session keying).
|
|
1572
|
+
mocks.getTrustEntries.mockImplementation((keys: string[]) => {
|
|
1573
|
+
const map = new Map<string, { m: number }>();
|
|
1574
|
+
const sessionKey = keys.find((key) => key.startsWith('session:'));
|
|
1575
|
+
if (sessionKey) {
|
|
1576
|
+
map.set(sessionKey, { m: 3 });
|
|
1577
|
+
}
|
|
1578
|
+
return Promise.resolve(map);
|
|
1579
|
+
});
|
|
1580
|
+
mocks.limit.mockResolvedValueOnce({
|
|
1581
|
+
success: false,
|
|
1582
|
+
limit: 1800,
|
|
1583
|
+
remaining: 0,
|
|
1584
|
+
reset: Date.now() + 15_000,
|
|
1585
|
+
});
|
|
1586
|
+
|
|
1587
|
+
const { guardApiProxyRequest, clearApiProxyGuardLimiterCache } =
|
|
1588
|
+
await import('../api-proxy-guard.js');
|
|
1589
|
+
clearApiProxyGuardLimiterCache();
|
|
1590
|
+
|
|
1591
|
+
const response = await guardApiProxyRequest(
|
|
1592
|
+
makeRequest('/api/v1/workspaces/ws-1/tasks?boardId=board-1', 'GET', {
|
|
1593
|
+
cookie:
|
|
1594
|
+
'sb-resolved-kingfish-21146-auth-token.0=base64-validvalue; theme=dark',
|
|
1595
|
+
}),
|
|
1596
|
+
{ prefixBase: 'proxy:test:api' }
|
|
1597
|
+
);
|
|
1598
|
+
|
|
1599
|
+
expect(response?.status).toBe(429);
|
|
1600
|
+
expect(response?.headers.get('X-RateLimit-Policy')).toBe('task-board-read');
|
|
1601
|
+
// Base task-board read minute limit (600) scaled by the trust tier (3).
|
|
1602
|
+
expect(mocks.ratelimitConfigs).toContainEqual({
|
|
1603
|
+
limit: 1800,
|
|
1604
|
+
window: '1 m',
|
|
1605
|
+
});
|
|
1606
|
+
// Trusted traffic gets a tier-suffixed bucket.
|
|
1607
|
+
expect(mocks.ratelimitPrefixes).toContain(
|
|
1608
|
+
'proxy:test:api:task-board-read:anonymous:t3:get:minute'
|
|
1609
|
+
);
|
|
1610
|
+
// The limiter is keyed by the per-session subject key, not the shared IP.
|
|
1611
|
+
const limiterId = mocks.limit.mock.calls[0]?.[0] as string;
|
|
1612
|
+
expect(limiterId.startsWith('session:')).toBe(true);
|
|
1613
|
+
});
|
|
1614
|
+
|
|
1615
|
+
it('uplifts a trusted location per-IP without per-session keying', async () => {
|
|
1616
|
+
vi.stubEnv('NODE_ENV', 'production');
|
|
1617
|
+
vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.test');
|
|
1618
|
+
vi.stubEnv('UPSTASH_REDIS_REST_TOKEN', 'token');
|
|
1619
|
+
mocks.redis.mockReturnValue({});
|
|
1620
|
+
mocks.extractIp.mockReturnValue('1.2.3.4');
|
|
1621
|
+
mocks.isBlocked.mockResolvedValue(null);
|
|
1622
|
+
// Trust the location (cidr) subject key only — no session cookie present.
|
|
1623
|
+
mocks.getTrustEntries.mockImplementation((keys: string[]) => {
|
|
1624
|
+
const map = new Map<string, { m: number }>();
|
|
1625
|
+
const cidrKey = keys.find((key) => key.startsWith('cidr:'));
|
|
1626
|
+
if (cidrKey) {
|
|
1627
|
+
map.set(cidrKey, { m: 2 });
|
|
1628
|
+
}
|
|
1629
|
+
return Promise.resolve(map);
|
|
1630
|
+
});
|
|
1631
|
+
mocks.limit.mockResolvedValueOnce({
|
|
1632
|
+
success: false,
|
|
1633
|
+
limit: 1200,
|
|
1634
|
+
remaining: 0,
|
|
1635
|
+
reset: Date.now() + 15_000,
|
|
1636
|
+
});
|
|
1637
|
+
|
|
1638
|
+
const { guardApiProxyRequest, clearApiProxyGuardLimiterCache } =
|
|
1639
|
+
await import('../api-proxy-guard.js');
|
|
1640
|
+
clearApiProxyGuardLimiterCache();
|
|
1641
|
+
|
|
1642
|
+
const response = await guardApiProxyRequest(
|
|
1643
|
+
makeRequest('/api/v1/workspaces/ws-1/tasks?boardId=board-1', 'GET'),
|
|
1644
|
+
{ prefixBase: 'proxy:test:api' }
|
|
1645
|
+
);
|
|
1646
|
+
|
|
1647
|
+
expect(response?.status).toBe(429);
|
|
1648
|
+
expect(mocks.ratelimitConfigs).toContainEqual({
|
|
1649
|
+
limit: 1200,
|
|
1650
|
+
window: '1 m',
|
|
1651
|
+
});
|
|
1652
|
+
expect(mocks.ratelimitPrefixes).toContain(
|
|
1653
|
+
'proxy:test:api:task-board-read:anonymous:t2:get:minute'
|
|
1654
|
+
);
|
|
1655
|
+
// Location trust keeps the shared per-IP bucket (whole team benefits).
|
|
1656
|
+
expect(mocks.limit).toHaveBeenCalledWith('ip:1.2.3.4');
|
|
1657
|
+
});
|
|
1658
|
+
|
|
1659
|
+
it('keeps a trusted location on per-IP keying even with an untrusted session cookie', async () => {
|
|
1660
|
+
vi.stubEnv('NODE_ENV', 'production');
|
|
1661
|
+
vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.test');
|
|
1662
|
+
vi.stubEnv('UPSTASH_REDIS_REST_TOKEN', 'token');
|
|
1663
|
+
vi.stubEnv('NEXT_PUBLIC_SUPABASE_URL', '');
|
|
1664
|
+
mocks.redis.mockReturnValue({});
|
|
1665
|
+
mocks.extractIp.mockReturnValue('1.2.3.4');
|
|
1666
|
+
mocks.isBlocked.mockResolvedValue(null);
|
|
1667
|
+
// Only the cidr is trusted; the session key is absent from the cache.
|
|
1668
|
+
mocks.getTrustEntries.mockImplementation((keys: string[]) => {
|
|
1669
|
+
const map = new Map<string, { m: number }>();
|
|
1670
|
+
const cidrKey = keys.find((key) => key.startsWith('cidr:'));
|
|
1671
|
+
if (cidrKey) {
|
|
1672
|
+
map.set(cidrKey, { m: 2 });
|
|
1673
|
+
}
|
|
1674
|
+
return Promise.resolve(map);
|
|
1675
|
+
});
|
|
1676
|
+
mocks.limit.mockResolvedValueOnce({
|
|
1677
|
+
success: false,
|
|
1678
|
+
limit: 1200,
|
|
1679
|
+
remaining: 0,
|
|
1680
|
+
reset: Date.now() + 15_000,
|
|
1681
|
+
});
|
|
1682
|
+
|
|
1683
|
+
const { guardApiProxyRequest, clearApiProxyGuardLimiterCache } =
|
|
1684
|
+
await import('../api-proxy-guard.js');
|
|
1685
|
+
clearApiProxyGuardLimiterCache();
|
|
1686
|
+
|
|
1687
|
+
await guardApiProxyRequest(
|
|
1688
|
+
makeRequest('/api/v1/workspaces/ws-1/tasks?boardId=board-1', 'GET', {
|
|
1689
|
+
cookie:
|
|
1690
|
+
'sb-resolved-kingfish-21146-auth-token.0=base64-validvalue; theme=dark',
|
|
1691
|
+
}),
|
|
1692
|
+
{ prefixBase: 'proxy:test:api' }
|
|
1693
|
+
);
|
|
1694
|
+
|
|
1695
|
+
// An untrusted session must not escape the per-IP bucket.
|
|
1696
|
+
expect(mocks.limit).toHaveBeenCalledWith('ip:1.2.3.4');
|
|
1697
|
+
});
|
|
1698
|
+
|
|
1699
|
+
it('keeps untrusted reads on the legacy per-IP bucket without trust suffix', async () => {
|
|
1700
|
+
vi.stubEnv('NODE_ENV', 'production');
|
|
1701
|
+
vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.test');
|
|
1702
|
+
vi.stubEnv('UPSTASH_REDIS_REST_TOKEN', 'token');
|
|
1703
|
+
mocks.redis.mockReturnValue({});
|
|
1704
|
+
mocks.extractIp.mockReturnValue('1.2.3.4');
|
|
1705
|
+
mocks.isBlocked.mockResolvedValue(null);
|
|
1706
|
+
// Default mock: empty trust map (forged or simply untrusted).
|
|
1707
|
+
mocks.limit.mockResolvedValueOnce({
|
|
1708
|
+
success: false,
|
|
1709
|
+
limit: 600,
|
|
1710
|
+
remaining: 0,
|
|
1711
|
+
reset: Date.now() + 15_000,
|
|
1712
|
+
});
|
|
1713
|
+
|
|
1714
|
+
const { guardApiProxyRequest, clearApiProxyGuardLimiterCache } =
|
|
1715
|
+
await import('../api-proxy-guard.js');
|
|
1716
|
+
clearApiProxyGuardLimiterCache();
|
|
1717
|
+
|
|
1718
|
+
const response = await guardApiProxyRequest(
|
|
1719
|
+
makeRequest('/api/v1/workspaces/ws-1/tasks?boardId=board-1', 'GET', {
|
|
1720
|
+
cookie:
|
|
1721
|
+
'sb-resolved-kingfish-21146-auth-token.0=forged-value; theme=dark',
|
|
1722
|
+
}),
|
|
1723
|
+
{ prefixBase: 'proxy:test:api' }
|
|
1724
|
+
);
|
|
1725
|
+
|
|
1726
|
+
expect(response?.status).toBe(429);
|
|
1727
|
+
expect(mocks.ratelimitConfigs).toContainEqual({
|
|
1728
|
+
limit: 600,
|
|
1729
|
+
window: '1 m',
|
|
1730
|
+
});
|
|
1731
|
+
expect(mocks.ratelimitPrefixes).toContain(
|
|
1732
|
+
'proxy:test:api:task-board-read:anonymous:get:minute'
|
|
1733
|
+
);
|
|
1734
|
+
expect(mocks.limit).toHaveBeenCalledWith('ip:1.2.3.4');
|
|
1735
|
+
});
|
|
1736
|
+
|
|
1737
|
+
it('applies an absolute read limit from a location rule', async () => {
|
|
1738
|
+
vi.stubEnv('NODE_ENV', 'production');
|
|
1739
|
+
vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.test');
|
|
1740
|
+
vi.stubEnv('UPSTASH_REDIS_REST_TOKEN', 'token');
|
|
1741
|
+
mocks.redis.mockReturnValue({});
|
|
1742
|
+
mocks.extractIp.mockReturnValue('1.2.3.4');
|
|
1743
|
+
mocks.isBlocked.mockResolvedValue(null);
|
|
1744
|
+
mocks.getTrustEntries.mockImplementation((keys: string[]) => {
|
|
1745
|
+
const map = new Map<string, unknown>();
|
|
1746
|
+
const cidrKey = keys.find((key) => key.startsWith('cidr:'));
|
|
1747
|
+
if (cidrKey) {
|
|
1748
|
+
map.set(cidrKey, { abs: { minute: 50 }, m: 1, mode: 'absolute' });
|
|
1749
|
+
}
|
|
1750
|
+
return Promise.resolve(map);
|
|
1751
|
+
});
|
|
1752
|
+
mocks.limit.mockResolvedValueOnce({
|
|
1753
|
+
success: false,
|
|
1754
|
+
limit: 50,
|
|
1755
|
+
remaining: 0,
|
|
1756
|
+
reset: Date.now() + 15_000,
|
|
1757
|
+
});
|
|
1758
|
+
|
|
1759
|
+
const { guardApiProxyRequest, clearApiProxyGuardLimiterCache } =
|
|
1760
|
+
await import('../api-proxy-guard.js');
|
|
1761
|
+
clearApiProxyGuardLimiterCache();
|
|
1762
|
+
|
|
1763
|
+
const response = await guardApiProxyRequest(
|
|
1764
|
+
makeRequest('/api/v1/workspaces/ws-1/tasks?boardId=board-1', 'GET'),
|
|
1765
|
+
{ prefixBase: 'proxy:test:api' }
|
|
1766
|
+
);
|
|
1767
|
+
|
|
1768
|
+
expect(response?.status).toBe(429);
|
|
1769
|
+
// Absolute exactly sets the minute window (even below the base 600).
|
|
1770
|
+
expect(mocks.ratelimitConfigs).toContainEqual({ limit: 50, window: '1 m' });
|
|
1771
|
+
expect(mocks.ratelimitPrefixes).toContain(
|
|
1772
|
+
'proxy:test:api:task-board-read:anonymous:abs-50-x-x:get:minute'
|
|
1773
|
+
);
|
|
1774
|
+
expect(mocks.limit).toHaveBeenCalledWith('ip:1.2.3.4');
|
|
1775
|
+
});
|
|
1776
|
+
|
|
1777
|
+
it('removes the read cap for an unlimited rule', async () => {
|
|
1778
|
+
vi.stubEnv('NODE_ENV', 'production');
|
|
1779
|
+
vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.test');
|
|
1780
|
+
vi.stubEnv('UPSTASH_REDIS_REST_TOKEN', 'token');
|
|
1781
|
+
mocks.redis.mockReturnValue({});
|
|
1782
|
+
mocks.extractIp.mockReturnValue('1.2.3.4');
|
|
1783
|
+
mocks.isBlocked.mockResolvedValue(null);
|
|
1784
|
+
mocks.getTrustEntries.mockImplementation((keys: string[]) => {
|
|
1785
|
+
const map = new Map<string, unknown>();
|
|
1786
|
+
const cidrKey = keys.find((key) => key.startsWith('cidr:'));
|
|
1787
|
+
if (cidrKey) {
|
|
1788
|
+
map.set(cidrKey, { m: 1, mode: 'unlimited' });
|
|
1789
|
+
}
|
|
1790
|
+
return Promise.resolve(map);
|
|
1791
|
+
});
|
|
1792
|
+
mocks.validateEmoji.mockResolvedValue(null);
|
|
1793
|
+
|
|
1794
|
+
const { guardApiProxyRequest, clearApiProxyGuardLimiterCache } =
|
|
1795
|
+
await import('../api-proxy-guard.js');
|
|
1796
|
+
clearApiProxyGuardLimiterCache();
|
|
1797
|
+
|
|
1798
|
+
const response = await guardApiProxyRequest(
|
|
1799
|
+
makeRequest('/api/v1/workspaces/ws-1/tasks?boardId=board-1', 'GET'),
|
|
1800
|
+
{ prefixBase: 'proxy:test:api' }
|
|
1801
|
+
);
|
|
1802
|
+
|
|
1803
|
+
// No read limiter is constructed → request passes the rate-limit stage.
|
|
1804
|
+
expect(response).toBeNull();
|
|
1805
|
+
expect(mocks.limit).not.toHaveBeenCalled();
|
|
1806
|
+
});
|
|
1807
|
+
|
|
1808
|
+
it('uplifts reads for a trusted workspace parsed from the path', async () => {
|
|
1809
|
+
vi.stubEnv('NODE_ENV', 'production');
|
|
1810
|
+
vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.test');
|
|
1811
|
+
vi.stubEnv('UPSTASH_REDIS_REST_TOKEN', 'token');
|
|
1812
|
+
mocks.redis.mockReturnValue({});
|
|
1813
|
+
mocks.extractIp.mockReturnValue('1.2.3.4');
|
|
1814
|
+
mocks.isBlocked.mockResolvedValue(null);
|
|
1815
|
+
const receivedKeys: string[] = [];
|
|
1816
|
+
mocks.getTrustEntries.mockImplementation((keys: string[]) => {
|
|
1817
|
+
receivedKeys.push(...keys);
|
|
1818
|
+
const map = new Map<string, unknown>();
|
|
1819
|
+
const workspaceKey = keys.find((key) => key.startsWith('workspace:'));
|
|
1820
|
+
if (workspaceKey) {
|
|
1821
|
+
map.set(workspaceKey, { m: 5 });
|
|
1822
|
+
}
|
|
1823
|
+
return Promise.resolve(map);
|
|
1824
|
+
});
|
|
1825
|
+
mocks.limit.mockResolvedValueOnce({
|
|
1826
|
+
success: false,
|
|
1827
|
+
limit: 3000,
|
|
1828
|
+
remaining: 0,
|
|
1829
|
+
reset: Date.now() + 15_000,
|
|
1830
|
+
});
|
|
1831
|
+
|
|
1832
|
+
const { guardApiProxyRequest, clearApiProxyGuardLimiterCache } =
|
|
1833
|
+
await import('../api-proxy-guard.js');
|
|
1834
|
+
clearApiProxyGuardLimiterCache();
|
|
1835
|
+
|
|
1836
|
+
const wsId = '33333333-3333-4333-8333-333333333333';
|
|
1837
|
+
const response = await guardApiProxyRequest(
|
|
1838
|
+
makeRequest(`/api/v1/workspaces/${wsId}/tasks?boardId=board-1`, 'GET'),
|
|
1839
|
+
{ prefixBase: 'proxy:test:api' }
|
|
1840
|
+
);
|
|
1841
|
+
|
|
1842
|
+
expect(response?.status).toBe(429);
|
|
1843
|
+
expect(receivedKeys).toContain(`workspace:${wsId}`);
|
|
1844
|
+
// Base task-board minute (600) scaled by the workspace tier (5).
|
|
1845
|
+
expect(mocks.ratelimitConfigs).toContainEqual({
|
|
1846
|
+
limit: 3000,
|
|
1847
|
+
window: '1 m',
|
|
1848
|
+
});
|
|
1849
|
+
expect(mocks.ratelimitPrefixes).toContain(
|
|
1850
|
+
'proxy:test:api:task-board-read:anonymous:t5:get:minute'
|
|
1851
|
+
);
|
|
1852
|
+
});
|
|
1853
|
+
|
|
1854
|
+
it('derives the session subject key the same way as server-side reputation', async () => {
|
|
1855
|
+
vi.stubEnv('NODE_ENV', 'production');
|
|
1856
|
+
vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.test');
|
|
1857
|
+
vi.stubEnv('UPSTASH_REDIS_REST_TOKEN', 'token');
|
|
1858
|
+
vi.stubEnv('NEXT_PUBLIC_SUPABASE_URL', '');
|
|
1859
|
+
mocks.redis.mockReturnValue({});
|
|
1860
|
+
mocks.extractIp.mockReturnValue('1.2.3.4');
|
|
1861
|
+
mocks.isBlocked.mockResolvedValue(null);
|
|
1862
|
+
const receivedKeys: string[] = [];
|
|
1863
|
+
mocks.getTrustEntries.mockImplementation((keys: string[]) => {
|
|
1864
|
+
receivedKeys.push(...keys);
|
|
1865
|
+
return Promise.resolve(new Map<string, { m: number }>());
|
|
1866
|
+
});
|
|
1867
|
+
mocks.limit.mockResolvedValue({
|
|
1868
|
+
success: true,
|
|
1869
|
+
limit: 600,
|
|
1870
|
+
remaining: 599,
|
|
1871
|
+
reset: Date.now() + 15_000,
|
|
1872
|
+
});
|
|
1873
|
+
mocks.validateEmoji.mockResolvedValue(null);
|
|
1874
|
+
|
|
1875
|
+
const { guardApiProxyRequest, clearApiProxyGuardLimiterCache } =
|
|
1876
|
+
await import('../api-proxy-guard.js');
|
|
1877
|
+
clearApiProxyGuardLimiterCache();
|
|
1878
|
+
|
|
1879
|
+
const cookieName = 'sb-resolved-kingfish-21146-auth-token.0';
|
|
1880
|
+
const cookieValue = 'base64-validvalue';
|
|
1881
|
+
await guardApiProxyRequest(
|
|
1882
|
+
makeRequest('/api/v1/workspaces/ws-1/tasks?boardId=board-1', 'GET', {
|
|
1883
|
+
cookie: `${cookieName}=${cookieValue}; theme=dark`,
|
|
1884
|
+
}),
|
|
1885
|
+
{ prefixBase: 'proxy:test:api' }
|
|
1886
|
+
);
|
|
1887
|
+
|
|
1888
|
+
const { createHash } = await import('node:crypto');
|
|
1889
|
+
const expected = `session:${createHash('sha256')
|
|
1890
|
+
.update(`${cookieName}:${cookieValue}`)
|
|
1891
|
+
.digest('hex')
|
|
1892
|
+
.slice(0, 24)}`;
|
|
1893
|
+
|
|
1894
|
+
expect(receivedKeys).toContain(expected);
|
|
1895
|
+
});
|
|
1896
|
+
|
|
1490
1897
|
it('skips text-bomb validation for whiteboard snapshots on the whiteboard save route', async () => {
|
|
1491
1898
|
vi.stubEnv('NODE_ENV', 'production');
|
|
1492
1899
|
vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.test');
|
|
@@ -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', () => {
|
|
@@ -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
|
};
|