@tuturuuu/utils 0.6.1 → 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 +41 -0
- package/biome.json +1 -1
- package/package.json +5 -4
- package/src/__tests__/api-proxy-guard.test.ts +503 -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 +371 -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/permissions.tsx +19 -0
- 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
|
@@ -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(
|
|
@@ -542,6 +550,90 @@ describe('guardApiProxyRequest', () => {
|
|
|
542
550
|
});
|
|
543
551
|
});
|
|
544
552
|
|
|
553
|
+
it('uses the users database read-over-post bucket for search and filter POST reads', async () => {
|
|
554
|
+
vi.stubEnv('NODE_ENV', 'production');
|
|
555
|
+
vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.test');
|
|
556
|
+
vi.stubEnv('UPSTASH_REDIS_REST_TOKEN', 'token');
|
|
557
|
+
mocks.redis.mockReturnValue({});
|
|
558
|
+
mocks.extractIp.mockReturnValue('1.2.3.4');
|
|
559
|
+
mocks.isBlocked.mockResolvedValue(null);
|
|
560
|
+
mocks.limit.mockResolvedValue({
|
|
561
|
+
success: false,
|
|
562
|
+
limit: 300,
|
|
563
|
+
remaining: 0,
|
|
564
|
+
reset: Date.now() + 15_000,
|
|
565
|
+
});
|
|
566
|
+
|
|
567
|
+
const { guardApiProxyRequest, clearApiProxyGuardLimiterCache } =
|
|
568
|
+
await import('../api-proxy-guard.js');
|
|
569
|
+
clearApiProxyGuardLimiterCache();
|
|
570
|
+
|
|
571
|
+
for (const path of [
|
|
572
|
+
'/api/v1/workspaces/ws-1/users/database',
|
|
573
|
+
'/api/v1/workspaces/ws-1/users/groups/featured-counts',
|
|
574
|
+
'/api/v1/workspaces/ws-1/users/groups/possible-excluded',
|
|
575
|
+
]) {
|
|
576
|
+
const response = await guardApiProxyRequest(makeRequest(path, 'POST'), {
|
|
577
|
+
prefixBase: 'proxy:test:api',
|
|
578
|
+
});
|
|
579
|
+
|
|
580
|
+
expect(response?.status).toBe(429);
|
|
581
|
+
expect(response?.headers.get('X-RateLimit-Limit')).toBe('300');
|
|
582
|
+
expect(response?.headers.get('X-RateLimit-Policy')).toBe(
|
|
583
|
+
'users-database-read-over-post'
|
|
584
|
+
);
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
expect(mocks.ratelimitConfigs).toContainEqual({
|
|
588
|
+
limit: 300,
|
|
589
|
+
window: '1 m',
|
|
590
|
+
});
|
|
591
|
+
expect(mocks.ratelimitConfigs).toContainEqual({
|
|
592
|
+
limit: 3000,
|
|
593
|
+
window: '1 h',
|
|
594
|
+
});
|
|
595
|
+
expect(mocks.ratelimitConfigs).toContainEqual({
|
|
596
|
+
limit: 20_000,
|
|
597
|
+
window: '1 d',
|
|
598
|
+
});
|
|
599
|
+
expect(mocks.ratelimitPrefixes).toContain(
|
|
600
|
+
'proxy:test:api:users-database-read-over-post:anonymous:mutate:minute'
|
|
601
|
+
);
|
|
602
|
+
});
|
|
603
|
+
|
|
604
|
+
it('keeps users mutations on the default mutation bucket', async () => {
|
|
605
|
+
vi.stubEnv('NODE_ENV', 'production');
|
|
606
|
+
vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.test');
|
|
607
|
+
vi.stubEnv('UPSTASH_REDIS_REST_TOKEN', 'token');
|
|
608
|
+
mocks.redis.mockReturnValue({});
|
|
609
|
+
mocks.extractIp.mockReturnValue('1.2.3.4');
|
|
610
|
+
mocks.isBlocked.mockResolvedValue(null);
|
|
611
|
+
mocks.limit.mockResolvedValue({
|
|
612
|
+
success: false,
|
|
613
|
+
limit: 30,
|
|
614
|
+
remaining: 0,
|
|
615
|
+
reset: Date.now() + 15_000,
|
|
616
|
+
});
|
|
617
|
+
|
|
618
|
+
const { guardApiProxyRequest, clearApiProxyGuardLimiterCache } =
|
|
619
|
+
await import('../api-proxy-guard.js');
|
|
620
|
+
clearApiProxyGuardLimiterCache();
|
|
621
|
+
|
|
622
|
+
const response = await guardApiProxyRequest(
|
|
623
|
+
makeRequest(
|
|
624
|
+
'/api/v1/workspaces/ws-1/users/11111111-1111-4111-8111-111111111111/referrals',
|
|
625
|
+
'POST'
|
|
626
|
+
),
|
|
627
|
+
{
|
|
628
|
+
prefixBase: 'proxy:test:api',
|
|
629
|
+
}
|
|
630
|
+
);
|
|
631
|
+
|
|
632
|
+
expect(response?.status).toBe(429);
|
|
633
|
+
expect(response?.headers.get('X-RateLimit-Limit')).toBe('30');
|
|
634
|
+
expect(response?.headers.get('X-RateLimit-Policy')).toBe('default');
|
|
635
|
+
});
|
|
636
|
+
|
|
545
637
|
it('keeps finance invoice mutations on the default mutation bucket', async () => {
|
|
546
638
|
vi.stubEnv('NODE_ENV', 'production');
|
|
547
639
|
vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.test');
|
|
@@ -574,7 +666,7 @@ describe('guardApiProxyRequest', () => {
|
|
|
574
666
|
}
|
|
575
667
|
|
|
576
668
|
expect(mocks.ratelimitPrefixes).toContain(
|
|
577
|
-
'proxy:test:api:default:anonymous
|
|
669
|
+
'proxy:test:api:default:anonymous:mutate:minute'
|
|
578
670
|
);
|
|
579
671
|
expect(mocks.ratelimitPrefixes).not.toContain(
|
|
580
672
|
'proxy:test:api:finance-invoice-create-read:anonymous:mutate:minute'
|
|
@@ -931,7 +1023,7 @@ describe('guardApiProxyRequest', () => {
|
|
|
931
1023
|
expect(mocks.limit).not.toHaveBeenCalled();
|
|
932
1024
|
});
|
|
933
1025
|
|
|
934
|
-
it('
|
|
1026
|
+
it('shares broad anonymous read buckets across path variants', async () => {
|
|
935
1027
|
vi.stubEnv('NODE_ENV', 'production');
|
|
936
1028
|
vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.test');
|
|
937
1029
|
vi.stubEnv('UPSTASH_REDIS_REST_TOKEN', 'token');
|
|
@@ -952,13 +1044,28 @@ describe('guardApiProxyRequest', () => {
|
|
|
952
1044
|
await guardApiProxyRequest(makeRequest('/api/v1/users/me/profile', 'GET'), {
|
|
953
1045
|
prefixBase: 'proxy:test:api',
|
|
954
1046
|
});
|
|
1047
|
+
await guardApiProxyRequest(
|
|
1048
|
+
makeRequest('/api/v1/users/me/nonexistent/random-nonce', 'GET'),
|
|
1049
|
+
{
|
|
1050
|
+
prefixBase: 'proxy:test:api',
|
|
1051
|
+
}
|
|
1052
|
+
);
|
|
955
1053
|
|
|
956
1054
|
expect(mocks.ratelimitPrefixes).toContain(
|
|
1055
|
+
'proxy:test:api:users-me:anonymous:get:minute'
|
|
1056
|
+
);
|
|
1057
|
+
expect(mocks.ratelimitPrefixes).not.toContain(
|
|
957
1058
|
'proxy:test:api:users-me:anonymous::api:v1:users:me:profile:get:minute'
|
|
958
1059
|
);
|
|
1060
|
+
expect(mocks.ratelimitPrefixes).not.toContain(
|
|
1061
|
+
'proxy:test:api:users-me:anonymous::api:v1:users:me:nonexistent:random-nonce:get:minute'
|
|
1062
|
+
);
|
|
959
1063
|
});
|
|
960
1064
|
|
|
961
|
-
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) => {
|
|
962
1069
|
vi.stubEnv('NODE_ENV', 'production');
|
|
963
1070
|
vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.test');
|
|
964
1071
|
vi.stubEnv('UPSTASH_REDIS_REST_TOKEN', 'token');
|
|
@@ -976,12 +1083,9 @@ describe('guardApiProxyRequest', () => {
|
|
|
976
1083
|
await import('../api-proxy-guard.js');
|
|
977
1084
|
clearApiProxyGuardLimiterCache();
|
|
978
1085
|
|
|
979
|
-
const response = await guardApiProxyRequest(
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
prefixBase: 'proxy:test:api',
|
|
983
|
-
}
|
|
984
|
-
);
|
|
1086
|
+
const response = await guardApiProxyRequest(makeRequest(path, 'POST'), {
|
|
1087
|
+
prefixBase: 'proxy:test:api',
|
|
1088
|
+
});
|
|
985
1089
|
|
|
986
1090
|
expect(response?.status).toBe(429);
|
|
987
1091
|
expect(response?.headers.get('X-RateLimit-Limit')).toBe('30');
|
|
@@ -1191,6 +1295,59 @@ describe('guardApiProxyRequest', () => {
|
|
|
1191
1295
|
expect(response?.headers.get('X-RateLimit-Policy')).toBe('high-fanout');
|
|
1192
1296
|
});
|
|
1193
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
|
+
|
|
1194
1351
|
it('uses relaxed buckets for task description persistence routes', async () => {
|
|
1195
1352
|
vi.stubEnv('NODE_ENV', 'production');
|
|
1196
1353
|
vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.test');
|
|
@@ -1403,6 +1560,340 @@ describe('guardApiProxyRequest', () => {
|
|
|
1403
1560
|
expect(mocks.validateEmoji).toHaveBeenCalledTimes(1);
|
|
1404
1561
|
});
|
|
1405
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
|
+
|
|
1406
1897
|
it('skips text-bomb validation for whiteboard snapshots on the whiteboard save route', async () => {
|
|
1407
1898
|
vi.stubEnv('NODE_ENV', 'production');
|
|
1408
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
|
};
|