@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
|
@@ -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(
|
|
@@ -593,6 +601,101 @@ describe('guardApiProxyRequest', () => {
|
|
|
593
601
|
);
|
|
594
602
|
});
|
|
595
603
|
|
|
604
|
+
it('uses a dedicated users-admin read bucket for lightweight user reads', 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: 1200,
|
|
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
|
+
for (const path of [
|
|
623
|
+
'/api/v1/workspaces/ws-1/users/database',
|
|
624
|
+
'/api/v1/workspaces/ws-1/users/groups',
|
|
625
|
+
'/api/v1/workspaces/ws-1/users/11111111-1111-4111-8111-111111111111/attendance',
|
|
626
|
+
'/api/v1/workspaces/ws-1/users/11111111-1111-4111-8111-111111111111/emails',
|
|
627
|
+
'/api/v1/workspaces/ws-1/users/11111111-1111-4111-8111-111111111111/referrals',
|
|
628
|
+
'/api/v1/workspaces/ws-1/users/feedbacks',
|
|
629
|
+
'/api/v1/workspaces/ws-1/user-groups/group-1',
|
|
630
|
+
'/api/v1/workspaces/ws-1/user-groups/group-1/members',
|
|
631
|
+
'/api/v1/workspaces/ws-1/user-groups/group-1/members/user-1/feedbacks',
|
|
632
|
+
'/api/v1/workspaces/ws-1/user-groups/group-1/attendance',
|
|
633
|
+
'/api/v1/workspaces/ws-1/user-groups/group-1/posts/post-1/status',
|
|
634
|
+
'/api/v1/workspaces/ws-1/user-groups/group-1/linked-products',
|
|
635
|
+
'/api/v1/workspaces/ws-1/user-groups/group-1/indicators/categories/category-1',
|
|
636
|
+
]) {
|
|
637
|
+
const response = await guardApiProxyRequest(makeRequest(path, 'GET'), {
|
|
638
|
+
prefixBase: 'proxy:test:api',
|
|
639
|
+
});
|
|
640
|
+
|
|
641
|
+
expect(response?.status).toBe(429);
|
|
642
|
+
expect(response?.headers.get('X-RateLimit-Limit')).toBe('1200');
|
|
643
|
+
expect(response?.headers.get('X-RateLimit-Policy')).toBe(
|
|
644
|
+
'users-admin-read'
|
|
645
|
+
);
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
expect(mocks.ratelimitConfigs).toContainEqual({
|
|
649
|
+
limit: 1200,
|
|
650
|
+
window: '1 m',
|
|
651
|
+
});
|
|
652
|
+
expect(mocks.ratelimitConfigs).toContainEqual({
|
|
653
|
+
limit: 12_000,
|
|
654
|
+
window: '1 h',
|
|
655
|
+
});
|
|
656
|
+
expect(mocks.ratelimitConfigs).toContainEqual({
|
|
657
|
+
limit: 80_000,
|
|
658
|
+
window: '1 d',
|
|
659
|
+
});
|
|
660
|
+
expect(mocks.ratelimitPrefixes).toContain(
|
|
661
|
+
'proxy:test:api:users-admin-read:anonymous:get:minute'
|
|
662
|
+
);
|
|
663
|
+
});
|
|
664
|
+
|
|
665
|
+
it('keeps user admin mutations on mutation buckets', async () => {
|
|
666
|
+
vi.stubEnv('NODE_ENV', 'production');
|
|
667
|
+
vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.test');
|
|
668
|
+
vi.stubEnv('UPSTASH_REDIS_REST_TOKEN', 'token');
|
|
669
|
+
mocks.redis.mockReturnValue({});
|
|
670
|
+
mocks.extractIp.mockReturnValue('1.2.3.4');
|
|
671
|
+
mocks.isBlocked.mockResolvedValue(null);
|
|
672
|
+
mocks.limit.mockResolvedValue({
|
|
673
|
+
success: false,
|
|
674
|
+
limit: 30,
|
|
675
|
+
remaining: 0,
|
|
676
|
+
reset: Date.now() + 15_000,
|
|
677
|
+
});
|
|
678
|
+
|
|
679
|
+
const { guardApiProxyRequest, clearApiProxyGuardLimiterCache } =
|
|
680
|
+
await import('../api-proxy-guard.js');
|
|
681
|
+
clearApiProxyGuardLimiterCache();
|
|
682
|
+
|
|
683
|
+
for (const path of [
|
|
684
|
+
'/api/v1/workspaces/ws-1/users/feedbacks',
|
|
685
|
+
'/api/v1/workspaces/ws-1/user-groups/group-1/members',
|
|
686
|
+
'/api/v1/workspaces/ws-1/user-groups/group-1/attendance',
|
|
687
|
+
'/api/v1/workspaces/ws-1/user-groups/group-1/linked-products',
|
|
688
|
+
]) {
|
|
689
|
+
const response = await guardApiProxyRequest(makeRequest(path, 'POST'), {
|
|
690
|
+
prefixBase: 'proxy:test:api',
|
|
691
|
+
});
|
|
692
|
+
|
|
693
|
+
expect(response?.status).toBe(429);
|
|
694
|
+
expect(response?.headers.get('X-RateLimit-Limit')).toBe('30');
|
|
695
|
+
expect(response?.headers.get('X-RateLimit-Policy')).toBe('default');
|
|
696
|
+
}
|
|
697
|
+
});
|
|
698
|
+
|
|
596
699
|
it('keeps users mutations on the default mutation bucket', async () => {
|
|
597
700
|
vi.stubEnv('NODE_ENV', 'production');
|
|
598
701
|
vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.test');
|
|
@@ -658,13 +761,117 @@ describe('guardApiProxyRequest', () => {
|
|
|
658
761
|
}
|
|
659
762
|
|
|
660
763
|
expect(mocks.ratelimitPrefixes).toContain(
|
|
661
|
-
'proxy:test:api:default:anonymous
|
|
764
|
+
'proxy:test:api:default:anonymous:mutate:minute'
|
|
662
765
|
);
|
|
663
766
|
expect(mocks.ratelimitPrefixes).not.toContain(
|
|
664
767
|
'proxy:test:api:finance-invoice-create-read:anonymous:mutate:minute'
|
|
665
768
|
);
|
|
666
769
|
});
|
|
667
770
|
|
|
771
|
+
it('keys verified calendar event deletes by authenticated session', async () => {
|
|
772
|
+
vi.stubEnv('NODE_ENV', 'production');
|
|
773
|
+
vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.test');
|
|
774
|
+
vi.stubEnv('UPSTASH_REDIS_REST_TOKEN', 'token');
|
|
775
|
+
vi.stubEnv('NEXT_PUBLIC_SUPABASE_URL', '');
|
|
776
|
+
mocks.redis.mockReturnValue({});
|
|
777
|
+
mocks.extractIp.mockReturnValue('1.2.3.4');
|
|
778
|
+
mocks.isBlocked.mockResolvedValue(null);
|
|
779
|
+
mocks.getTrustEntries.mockImplementation((keys: string[]) => {
|
|
780
|
+
const map = new Map<string, { m: number; verified: true }>();
|
|
781
|
+
const sessionKey = keys.find((key) => key.startsWith('session:'));
|
|
782
|
+
if (sessionKey) {
|
|
783
|
+
map.set(sessionKey, { m: 1, verified: true });
|
|
784
|
+
}
|
|
785
|
+
return Promise.resolve(map);
|
|
786
|
+
});
|
|
787
|
+
mocks.limit.mockResolvedValueOnce({
|
|
788
|
+
success: false,
|
|
789
|
+
limit: 60,
|
|
790
|
+
remaining: 0,
|
|
791
|
+
reset: Date.now() + 15_000,
|
|
792
|
+
});
|
|
793
|
+
|
|
794
|
+
const { guardApiProxyRequest, clearApiProxyGuardLimiterCache } =
|
|
795
|
+
await import('../api-proxy-guard.js');
|
|
796
|
+
clearApiProxyGuardLimiterCache();
|
|
797
|
+
|
|
798
|
+
const response = await guardApiProxyRequest(
|
|
799
|
+
makeRequest(
|
|
800
|
+
'/api/v1/workspaces/071e0fc7-9aa8-42d8-92e5-cc9b3aeec2f1/calendar/events/68e18a3a-c527-421e-8ac3-f8b846175622',
|
|
801
|
+
'DELETE',
|
|
802
|
+
{
|
|
803
|
+
cookie:
|
|
804
|
+
'sb-resolved-kingfish-21146-auth-token.0=base64-validvalue; theme=dark',
|
|
805
|
+
}
|
|
806
|
+
),
|
|
807
|
+
{ prefixBase: 'proxy:test:api' }
|
|
808
|
+
);
|
|
809
|
+
|
|
810
|
+
expect(response?.status).toBe(429);
|
|
811
|
+
expect(response?.headers.get('X-RateLimit-Caller-Class')).toBe(
|
|
812
|
+
'authenticated'
|
|
813
|
+
);
|
|
814
|
+
expect(response?.headers.get('X-RateLimit-Limit')).toBe('60');
|
|
815
|
+
expect(response?.headers.get('X-RateLimit-Policy')).toBe('default');
|
|
816
|
+
expect(mocks.ratelimitConfigs).toContainEqual({
|
|
817
|
+
limit: 60,
|
|
818
|
+
window: '1 m',
|
|
819
|
+
});
|
|
820
|
+
expect(mocks.ratelimitPrefixes).toContain(
|
|
821
|
+
'proxy:test:api:default:authenticated:mutate:minute'
|
|
822
|
+
);
|
|
823
|
+
expect(mocks.ratelimitPrefixes).not.toContain(
|
|
824
|
+
'proxy:test:api:default:authenticated:t1:mutate:minute'
|
|
825
|
+
);
|
|
826
|
+
const limiterId = mocks.limit.mock.calls[0]?.[0] as string;
|
|
827
|
+
expect(limiterId.startsWith('session:')).toBe(true);
|
|
828
|
+
});
|
|
829
|
+
|
|
830
|
+
it('keeps unverified calendar event deletes on the anonymous IP bucket', async () => {
|
|
831
|
+
vi.stubEnv('NODE_ENV', 'production');
|
|
832
|
+
vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.test');
|
|
833
|
+
vi.stubEnv('UPSTASH_REDIS_REST_TOKEN', 'token');
|
|
834
|
+
vi.stubEnv('NEXT_PUBLIC_SUPABASE_URL', '');
|
|
835
|
+
mocks.redis.mockReturnValue({});
|
|
836
|
+
mocks.extractIp.mockReturnValue('1.2.3.4');
|
|
837
|
+
mocks.isBlocked.mockResolvedValue(null);
|
|
838
|
+
mocks.limit.mockResolvedValueOnce({
|
|
839
|
+
success: false,
|
|
840
|
+
limit: 30,
|
|
841
|
+
remaining: 0,
|
|
842
|
+
reset: Date.now() + 15_000,
|
|
843
|
+
});
|
|
844
|
+
|
|
845
|
+
const { guardApiProxyRequest, clearApiProxyGuardLimiterCache } =
|
|
846
|
+
await import('../api-proxy-guard.js');
|
|
847
|
+
clearApiProxyGuardLimiterCache();
|
|
848
|
+
|
|
849
|
+
const response = await guardApiProxyRequest(
|
|
850
|
+
makeRequest(
|
|
851
|
+
'/api/v1/workspaces/071e0fc7-9aa8-42d8-92e5-cc9b3aeec2f1/calendar/events/68e18a3a-c527-421e-8ac3-f8b846175622',
|
|
852
|
+
'DELETE',
|
|
853
|
+
{
|
|
854
|
+
cookie:
|
|
855
|
+
'sb-resolved-kingfish-21146-auth-token.0=forged-value; theme=dark',
|
|
856
|
+
}
|
|
857
|
+
),
|
|
858
|
+
{ prefixBase: 'proxy:test:api' }
|
|
859
|
+
);
|
|
860
|
+
|
|
861
|
+
expect(response?.status).toBe(429);
|
|
862
|
+
expect(response?.headers.get('X-RateLimit-Caller-Class')).toBe('anonymous');
|
|
863
|
+
expect(response?.headers.get('X-RateLimit-Limit')).toBe('30');
|
|
864
|
+
expect(response?.headers.get('X-RateLimit-Policy')).toBe('default');
|
|
865
|
+
expect(mocks.ratelimitConfigs).toContainEqual({
|
|
866
|
+
limit: 30,
|
|
867
|
+
window: '1 m',
|
|
868
|
+
});
|
|
869
|
+
expect(mocks.ratelimitPrefixes).toContain(
|
|
870
|
+
'proxy:test:api:default:anonymous:mutate:minute'
|
|
871
|
+
);
|
|
872
|
+
expect(mocks.limit).toHaveBeenCalledWith('ip:1.2.3.4');
|
|
873
|
+
});
|
|
874
|
+
|
|
668
875
|
it('keeps unauthenticated cron reads on a strict proxy bucket', async () => {
|
|
669
876
|
vi.stubEnv('NODE_ENV', 'production');
|
|
670
877
|
vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.test');
|
|
@@ -844,16 +1051,25 @@ describe('guardApiProxyRequest', () => {
|
|
|
844
1051
|
expect(response?.headers.get('X-RateLimit-Caller-Class')).toBe('anonymous');
|
|
845
1052
|
});
|
|
846
1053
|
|
|
847
|
-
it('
|
|
1054
|
+
it('keys verified users/me reads by session without applying a trust uplift', async () => {
|
|
848
1055
|
vi.stubEnv('NODE_ENV', 'production');
|
|
849
1056
|
vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.test');
|
|
850
1057
|
vi.stubEnv('UPSTASH_REDIS_REST_TOKEN', 'token');
|
|
1058
|
+
vi.stubEnv('NEXT_PUBLIC_SUPABASE_URL', '');
|
|
851
1059
|
mocks.redis.mockReturnValue({});
|
|
852
1060
|
mocks.extractIp.mockReturnValue('1.2.3.4');
|
|
853
1061
|
mocks.isBlocked.mockResolvedValue(null);
|
|
1062
|
+
mocks.getTrustEntries.mockImplementation((keys: string[]) => {
|
|
1063
|
+
const map = new Map<string, { m: number; verified: true }>();
|
|
1064
|
+
const sessionKey = keys.find((key) => key.startsWith('session:'));
|
|
1065
|
+
if (sessionKey) {
|
|
1066
|
+
map.set(sessionKey, { m: 1, verified: true });
|
|
1067
|
+
}
|
|
1068
|
+
return Promise.resolve(map);
|
|
1069
|
+
});
|
|
854
1070
|
mocks.limit.mockResolvedValueOnce({
|
|
855
1071
|
success: false,
|
|
856
|
-
limit:
|
|
1072
|
+
limit: 600,
|
|
857
1073
|
remaining: 0,
|
|
858
1074
|
reset: Date.now() + 15_000,
|
|
859
1075
|
});
|
|
@@ -863,18 +1079,34 @@ describe('guardApiProxyRequest', () => {
|
|
|
863
1079
|
clearApiProxyGuardLimiterCache();
|
|
864
1080
|
|
|
865
1081
|
const response = await guardApiProxyRequest(
|
|
866
|
-
makeRequest('/api/v1/
|
|
867
|
-
|
|
1082
|
+
makeRequest('/api/v1/users/me/profile', 'GET', {
|
|
1083
|
+
cookie:
|
|
1084
|
+
'sb-resolved-kingfish-21146-auth-token.0=base64-validvalue; theme=dark',
|
|
868
1085
|
}),
|
|
869
1086
|
{ prefixBase: 'proxy:test:api' }
|
|
870
1087
|
);
|
|
871
1088
|
|
|
872
1089
|
expect(response?.status).toBe(429);
|
|
873
|
-
expect(response?.headers.get('X-RateLimit-Caller-Class')).toBe(
|
|
874
|
-
|
|
1090
|
+
expect(response?.headers.get('X-RateLimit-Caller-Class')).toBe(
|
|
1091
|
+
'authenticated'
|
|
1092
|
+
);
|
|
1093
|
+
expect(response?.headers.get('X-RateLimit-Limit')).toBe('600');
|
|
1094
|
+
expect(response?.headers.get('X-RateLimit-Policy')).toBe('users-me');
|
|
1095
|
+
expect(mocks.ratelimitConfigs).toContainEqual({
|
|
1096
|
+
limit: 600,
|
|
1097
|
+
window: '1 m',
|
|
1098
|
+
});
|
|
1099
|
+
expect(mocks.ratelimitPrefixes).toContain(
|
|
1100
|
+
'proxy:test:api:users-me:authenticated:get:minute'
|
|
1101
|
+
);
|
|
1102
|
+
expect(mocks.ratelimitPrefixes).not.toContain(
|
|
1103
|
+
'proxy:test:api:users-me:authenticated:t1:get:minute'
|
|
1104
|
+
);
|
|
1105
|
+
const limiterId = mocks.limit.mock.calls[0]?.[0] as string;
|
|
1106
|
+
expect(limiterId.startsWith('session:')).toBe(true);
|
|
875
1107
|
});
|
|
876
1108
|
|
|
877
|
-
it('
|
|
1109
|
+
it('uses the authenticated workspace read policy for verified dashboard reads', async () => {
|
|
878
1110
|
vi.stubEnv('NODE_ENV', 'production');
|
|
879
1111
|
vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.test');
|
|
880
1112
|
vi.stubEnv('UPSTASH_REDIS_REST_TOKEN', 'token');
|
|
@@ -882,9 +1114,17 @@ describe('guardApiProxyRequest', () => {
|
|
|
882
1114
|
mocks.redis.mockReturnValue({});
|
|
883
1115
|
mocks.extractIp.mockReturnValue('1.2.3.4');
|
|
884
1116
|
mocks.isBlocked.mockResolvedValue(null);
|
|
1117
|
+
mocks.getTrustEntries.mockImplementation((keys: string[]) => {
|
|
1118
|
+
const map = new Map<string, { m: number; verified: true }>();
|
|
1119
|
+
const sessionKey = keys.find((key) => key.startsWith('session:'));
|
|
1120
|
+
if (sessionKey) {
|
|
1121
|
+
map.set(sessionKey, { m: 1, verified: true });
|
|
1122
|
+
}
|
|
1123
|
+
return Promise.resolve(map);
|
|
1124
|
+
});
|
|
885
1125
|
mocks.limit.mockResolvedValueOnce({
|
|
886
1126
|
success: false,
|
|
887
|
-
limit:
|
|
1127
|
+
limit: 600,
|
|
888
1128
|
remaining: 0,
|
|
889
1129
|
reset: Date.now() + 15_000,
|
|
890
1130
|
});
|
|
@@ -894,7 +1134,7 @@ describe('guardApiProxyRequest', () => {
|
|
|
894
1134
|
clearApiProxyGuardLimiterCache();
|
|
895
1135
|
|
|
896
1136
|
const response = await guardApiProxyRequest(
|
|
897
|
-
makeRequest('/api/v1/
|
|
1137
|
+
makeRequest('/api/v1/workspaces/ws-1/dashboard/bootstrap', 'GET', {
|
|
898
1138
|
cookie:
|
|
899
1139
|
'sb-resolved-kingfish-21146-auth-token.0=base64-validvalue; theme=dark',
|
|
900
1140
|
}),
|
|
@@ -902,22 +1142,38 @@ describe('guardApiProxyRequest', () => {
|
|
|
902
1142
|
);
|
|
903
1143
|
|
|
904
1144
|
expect(response?.status).toBe(429);
|
|
905
|
-
expect(response?.headers.get('X-RateLimit-Caller-Class')).toBe(
|
|
906
|
-
|
|
1145
|
+
expect(response?.headers.get('X-RateLimit-Caller-Class')).toBe(
|
|
1146
|
+
'authenticated'
|
|
1147
|
+
);
|
|
1148
|
+
expect(response?.headers.get('X-RateLimit-Policy')).toBe(
|
|
1149
|
+
'workspace-dashboard-read'
|
|
1150
|
+
);
|
|
1151
|
+
expect(mocks.ratelimitPrefixes).toContain(
|
|
1152
|
+
'proxy:test:api:workspace-dashboard-read:authenticated:get:minute'
|
|
1153
|
+
);
|
|
1154
|
+
const limiterId = mocks.limit.mock.calls[0]?.[0] as string;
|
|
1155
|
+
expect(limiterId.startsWith('session:')).toBe(true);
|
|
907
1156
|
});
|
|
908
1157
|
|
|
909
|
-
it('
|
|
1158
|
+
it('uses authenticated default read limits for verified sessions on generic reads', async () => {
|
|
910
1159
|
vi.stubEnv('NODE_ENV', 'production');
|
|
911
1160
|
vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.test');
|
|
912
1161
|
vi.stubEnv('UPSTASH_REDIS_REST_TOKEN', 'token');
|
|
913
|
-
vi.stubEnv('NEXT_PUBLIC_SUPABASE_URL', '
|
|
914
|
-
vi.stubEnv('SUPABASE_SERVER_URL', 'http://host.docker.internal:8001/');
|
|
1162
|
+
vi.stubEnv('NEXT_PUBLIC_SUPABASE_URL', '');
|
|
915
1163
|
mocks.redis.mockReturnValue({});
|
|
916
1164
|
mocks.extractIp.mockReturnValue('1.2.3.4');
|
|
917
1165
|
mocks.isBlocked.mockResolvedValue(null);
|
|
1166
|
+
mocks.getTrustEntries.mockImplementation((keys: string[]) => {
|
|
1167
|
+
const map = new Map<string, { m: number; verified: true }>();
|
|
1168
|
+
const sessionKey = keys.find((key) => key.startsWith('session:'));
|
|
1169
|
+
if (sessionKey) {
|
|
1170
|
+
map.set(sessionKey, { m: 1, verified: true });
|
|
1171
|
+
}
|
|
1172
|
+
return Promise.resolve(map);
|
|
1173
|
+
});
|
|
918
1174
|
mocks.limit.mockResolvedValueOnce({
|
|
919
1175
|
success: false,
|
|
920
|
-
limit:
|
|
1176
|
+
limit: 600,
|
|
921
1177
|
remaining: 0,
|
|
922
1178
|
reset: Date.now() + 15_000,
|
|
923
1179
|
});
|
|
@@ -927,27 +1183,48 @@ describe('guardApiProxyRequest', () => {
|
|
|
927
1183
|
clearApiProxyGuardLimiterCache();
|
|
928
1184
|
|
|
929
1185
|
const response = await guardApiProxyRequest(
|
|
930
|
-
makeRequest('/api/v1/
|
|
931
|
-
cookie:
|
|
1186
|
+
makeRequest('/api/v1/unknown/read-only-dashboard-fragment', 'GET', {
|
|
1187
|
+
cookie:
|
|
1188
|
+
'sb-resolved-kingfish-21146-auth-token.0=base64-validvalue; theme=dark',
|
|
932
1189
|
}),
|
|
933
1190
|
{ prefixBase: 'proxy:test:api' }
|
|
934
1191
|
);
|
|
935
1192
|
|
|
936
1193
|
expect(response?.status).toBe(429);
|
|
937
|
-
expect(response?.headers.get('X-RateLimit-Caller-Class')).toBe(
|
|
938
|
-
|
|
1194
|
+
expect(response?.headers.get('X-RateLimit-Caller-Class')).toBe(
|
|
1195
|
+
'authenticated'
|
|
1196
|
+
);
|
|
1197
|
+
expect(response?.headers.get('X-RateLimit-Limit')).toBe('600');
|
|
1198
|
+
expect(response?.headers.get('X-RateLimit-Policy')).toBe('default');
|
|
1199
|
+
expect(mocks.ratelimitPrefixes).toContain(
|
|
1200
|
+
'proxy:test:api:default:authenticated:get:minute'
|
|
1201
|
+
);
|
|
1202
|
+
expect(mocks.ratelimitPrefixes).not.toContain(
|
|
1203
|
+
'proxy:test:api:default:authenticated:t1:get:minute'
|
|
1204
|
+
);
|
|
1205
|
+
const limiterId = mocks.limit.mock.calls[0]?.[0] as string;
|
|
1206
|
+
expect(limiterId.startsWith('session:')).toBe(true);
|
|
939
1207
|
});
|
|
940
1208
|
|
|
941
|
-
it('
|
|
1209
|
+
it('covers legacy workspace reads with the verified workspace dashboard policy', async () => {
|
|
942
1210
|
vi.stubEnv('NODE_ENV', 'production');
|
|
943
1211
|
vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.test');
|
|
944
1212
|
vi.stubEnv('UPSTASH_REDIS_REST_TOKEN', 'token');
|
|
1213
|
+
vi.stubEnv('NEXT_PUBLIC_SUPABASE_URL', '');
|
|
945
1214
|
mocks.redis.mockReturnValue({});
|
|
946
1215
|
mocks.extractIp.mockReturnValue('1.2.3.4');
|
|
947
1216
|
mocks.isBlocked.mockResolvedValue(null);
|
|
1217
|
+
mocks.getTrustEntries.mockImplementation((keys: string[]) => {
|
|
1218
|
+
const map = new Map<string, { m: number; verified: true }>();
|
|
1219
|
+
const sessionKey = keys.find((key) => key.startsWith('session:'));
|
|
1220
|
+
if (sessionKey) {
|
|
1221
|
+
map.set(sessionKey, { m: 1, verified: true });
|
|
1222
|
+
}
|
|
1223
|
+
return Promise.resolve(map);
|
|
1224
|
+
});
|
|
948
1225
|
mocks.limit.mockResolvedValueOnce({
|
|
949
1226
|
success: false,
|
|
950
|
-
limit:
|
|
1227
|
+
limit: 600,
|
|
951
1228
|
remaining: 0,
|
|
952
1229
|
reset: Date.now() + 15_000,
|
|
953
1230
|
});
|
|
@@ -957,43 +1234,39 @@ describe('guardApiProxyRequest', () => {
|
|
|
957
1234
|
clearApiProxyGuardLimiterCache();
|
|
958
1235
|
|
|
959
1236
|
const response = await guardApiProxyRequest(
|
|
960
|
-
makeRequest('/api/
|
|
961
|
-
cookie:
|
|
1237
|
+
makeRequest('/api/workspaces/ws-1/settings', 'GET', {
|
|
1238
|
+
cookie:
|
|
1239
|
+
'sb-resolved-kingfish-21146-auth-token.0=base64-validvalue; theme=dark',
|
|
962
1240
|
}),
|
|
963
|
-
{ prefixBase: 'proxy:
|
|
1241
|
+
{ prefixBase: 'proxy:test:api' }
|
|
964
1242
|
);
|
|
965
1243
|
|
|
966
1244
|
expect(response?.status).toBe(429);
|
|
967
|
-
expect(response?.headers.get('X-RateLimit-Caller-Class')).toBe(
|
|
968
|
-
|
|
969
|
-
});
|
|
970
|
-
|
|
971
|
-
it('does not classify raw app-session cookies as authenticated sessions', async () => {
|
|
972
|
-
const { hasAuthenticatedApiSession } = await import(
|
|
973
|
-
'../api-proxy-guard.js'
|
|
1245
|
+
expect(response?.headers.get('X-RateLimit-Caller-Class')).toBe(
|
|
1246
|
+
'authenticated'
|
|
974
1247
|
);
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
).toBe(
|
|
1248
|
+
expect(response?.headers.get('X-RateLimit-Policy')).toBe(
|
|
1249
|
+
'workspace-dashboard-read'
|
|
1250
|
+
);
|
|
1251
|
+
expect(mocks.ratelimitPrefixes).toContain(
|
|
1252
|
+
'proxy:test:api:workspace-dashboard-read:authenticated:get:minute'
|
|
1253
|
+
);
|
|
1254
|
+
const limiterId = mocks.limit.mock.calls[0]?.[0] as string;
|
|
1255
|
+
expect(limiterId.startsWith('session:')).toBe(true);
|
|
983
1256
|
});
|
|
984
1257
|
|
|
985
|
-
it('
|
|
1258
|
+
it('rate limits API client-looking bearer reads until route auth validates them', async () => {
|
|
986
1259
|
vi.stubEnv('NODE_ENV', 'production');
|
|
987
1260
|
vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.test');
|
|
988
1261
|
vi.stubEnv('UPSTASH_REDIS_REST_TOKEN', 'token');
|
|
989
|
-
vi.stubEnv('NEXT_PUBLIC_SUPABASE_URL', '');
|
|
990
1262
|
mocks.redis.mockReturnValue({});
|
|
991
1263
|
mocks.extractIp.mockReturnValue('1.2.3.4');
|
|
992
|
-
mocks.isBlocked.mockResolvedValue(
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
1264
|
+
mocks.isBlocked.mockResolvedValue(null);
|
|
1265
|
+
mocks.limit.mockResolvedValueOnce({
|
|
1266
|
+
success: false,
|
|
1267
|
+
limit: 20,
|
|
1268
|
+
remaining: 0,
|
|
1269
|
+
reset: Date.now() + 15_000,
|
|
997
1270
|
});
|
|
998
1271
|
|
|
999
1272
|
const { guardApiProxyRequest, clearApiProxyGuardLimiterCache } =
|
|
@@ -1002,20 +1275,158 @@ describe('guardApiProxyRequest', () => {
|
|
|
1002
1275
|
|
|
1003
1276
|
const response = await guardApiProxyRequest(
|
|
1004
1277
|
makeRequest('/api/v1/workspaces/ws-1/users/database?page=1', 'GET', {
|
|
1005
|
-
|
|
1006
|
-
'sb-resolved-kingfish-21146-auth-token.0=base64-validvalue; theme=dark',
|
|
1278
|
+
authorization: 'Bearer ttr_test_key',
|
|
1007
1279
|
}),
|
|
1008
1280
|
{ prefixBase: 'proxy:test:api' }
|
|
1009
1281
|
);
|
|
1010
1282
|
|
|
1011
1283
|
expect(response?.status).toBe(429);
|
|
1012
|
-
expect(response?.headers.get('X-
|
|
1013
|
-
|
|
1284
|
+
expect(response?.headers.get('X-RateLimit-Caller-Class')).toBe('anonymous');
|
|
1285
|
+
expect(mocks.limit).toHaveBeenCalledTimes(1);
|
|
1286
|
+
});
|
|
1287
|
+
|
|
1288
|
+
it('rate limits Supabase-cookie-looking reads until route auth validates them', async () => {
|
|
1289
|
+
vi.stubEnv('NODE_ENV', 'production');
|
|
1290
|
+
vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.test');
|
|
1291
|
+
vi.stubEnv('UPSTASH_REDIS_REST_TOKEN', 'token');
|
|
1292
|
+
vi.stubEnv('NEXT_PUBLIC_SUPABASE_URL', '');
|
|
1293
|
+
mocks.redis.mockReturnValue({});
|
|
1294
|
+
mocks.extractIp.mockReturnValue('1.2.3.4');
|
|
1295
|
+
mocks.isBlocked.mockResolvedValue(null);
|
|
1296
|
+
mocks.limit.mockResolvedValueOnce({
|
|
1297
|
+
success: false,
|
|
1298
|
+
limit: 20,
|
|
1299
|
+
remaining: 0,
|
|
1300
|
+
reset: Date.now() + 15_000,
|
|
1301
|
+
});
|
|
1302
|
+
|
|
1303
|
+
const { guardApiProxyRequest, clearApiProxyGuardLimiterCache } =
|
|
1304
|
+
await import('../api-proxy-guard.js');
|
|
1305
|
+
clearApiProxyGuardLimiterCache();
|
|
1306
|
+
|
|
1307
|
+
const response = await guardApiProxyRequest(
|
|
1308
|
+
makeRequest('/api/v1/users/me/profile', 'GET', {
|
|
1309
|
+
cookie:
|
|
1310
|
+
'sb-resolved-kingfish-21146-auth-token.0=base64-validvalue; theme=dark',
|
|
1311
|
+
}),
|
|
1312
|
+
{ prefixBase: 'proxy:test:api' }
|
|
1313
|
+
);
|
|
1314
|
+
|
|
1315
|
+
expect(response?.status).toBe(429);
|
|
1316
|
+
expect(response?.headers.get('X-RateLimit-Caller-Class')).toBe('anonymous');
|
|
1317
|
+
expect(mocks.limit).toHaveBeenCalledTimes(1);
|
|
1318
|
+
});
|
|
1319
|
+
|
|
1320
|
+
it('rate limits server-url Supabase-cookie-looking reads until route auth validates them', async () => {
|
|
1321
|
+
vi.stubEnv('NODE_ENV', 'production');
|
|
1322
|
+
vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.test');
|
|
1323
|
+
vi.stubEnv('UPSTASH_REDIS_REST_TOKEN', 'token');
|
|
1324
|
+
vi.stubEnv('NEXT_PUBLIC_SUPABASE_URL', 'http://127.0.0.1:8001');
|
|
1325
|
+
vi.stubEnv('SUPABASE_SERVER_URL', 'http://host.docker.internal:8001/');
|
|
1326
|
+
mocks.redis.mockReturnValue({});
|
|
1327
|
+
mocks.extractIp.mockReturnValue('1.2.3.4');
|
|
1328
|
+
mocks.isBlocked.mockResolvedValue(null);
|
|
1329
|
+
mocks.limit.mockResolvedValueOnce({
|
|
1330
|
+
success: false,
|
|
1331
|
+
limit: 20,
|
|
1332
|
+
remaining: 0,
|
|
1333
|
+
reset: Date.now() + 15_000,
|
|
1334
|
+
});
|
|
1335
|
+
|
|
1336
|
+
const { guardApiProxyRequest, clearApiProxyGuardLimiterCache } =
|
|
1337
|
+
await import('../api-proxy-guard.js');
|
|
1338
|
+
clearApiProxyGuardLimiterCache();
|
|
1339
|
+
|
|
1340
|
+
const response = await guardApiProxyRequest(
|
|
1341
|
+
makeRequest('/api/v1/users/me/configs/demo', 'GET', {
|
|
1342
|
+
cookie: 'sb-host-auth-token=base64-validvalue; theme=dark',
|
|
1343
|
+
}),
|
|
1344
|
+
{ prefixBase: 'proxy:test:api' }
|
|
1345
|
+
);
|
|
1346
|
+
|
|
1347
|
+
expect(response?.status).toBe(429);
|
|
1348
|
+
expect(response?.headers.get('X-RateLimit-Caller-Class')).toBe('anonymous');
|
|
1349
|
+
expect(mocks.limit).toHaveBeenCalledTimes(1);
|
|
1350
|
+
});
|
|
1351
|
+
|
|
1352
|
+
it('rate limits app-session-cookie-looking satellite traffic until route auth validates it', async () => {
|
|
1353
|
+
vi.stubEnv('NODE_ENV', 'production');
|
|
1354
|
+
vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.test');
|
|
1355
|
+
vi.stubEnv('UPSTASH_REDIS_REST_TOKEN', 'token');
|
|
1356
|
+
mocks.redis.mockReturnValue({});
|
|
1357
|
+
mocks.extractIp.mockReturnValue('1.2.3.4');
|
|
1358
|
+
mocks.isBlocked.mockResolvedValue(null);
|
|
1359
|
+
mocks.limit.mockResolvedValueOnce({
|
|
1360
|
+
success: false,
|
|
1361
|
+
limit: 20,
|
|
1362
|
+
remaining: 0,
|
|
1363
|
+
reset: Date.now() + 15_000,
|
|
1364
|
+
});
|
|
1365
|
+
|
|
1366
|
+
const { guardApiProxyRequest, clearApiProxyGuardLimiterCache } =
|
|
1367
|
+
await import('../api-proxy-guard.js');
|
|
1368
|
+
clearApiProxyGuardLimiterCache();
|
|
1369
|
+
|
|
1370
|
+
const response = await guardApiProxyRequest(
|
|
1371
|
+
makeRequest('/api/v1/workspaces/ws-1/tasks?boardId=board-1', 'GET', {
|
|
1372
|
+
cookie: 'tuturuuu_app_session=app-session-token; theme=dark',
|
|
1373
|
+
}),
|
|
1374
|
+
{ prefixBase: 'proxy:tasks:api' }
|
|
1375
|
+
);
|
|
1376
|
+
|
|
1377
|
+
expect(response?.status).toBe(429);
|
|
1378
|
+
expect(response?.headers.get('X-RateLimit-Caller-Class')).toBe('anonymous');
|
|
1379
|
+
expect(mocks.limit).toHaveBeenCalledTimes(1);
|
|
1380
|
+
});
|
|
1381
|
+
|
|
1382
|
+
it('does not classify raw app-session cookies as authenticated sessions', async () => {
|
|
1383
|
+
const { hasAuthenticatedApiSession } = await import(
|
|
1384
|
+
'../api-proxy-guard.js'
|
|
1385
|
+
);
|
|
1386
|
+
|
|
1387
|
+
expect(
|
|
1388
|
+
hasAuthenticatedApiSession(
|
|
1389
|
+
makeRequest('/api/v1/workspaces/ws-1/tasks', 'GET', {
|
|
1390
|
+
cookie: 'tuturuuu_app_session=anything; theme=dark',
|
|
1391
|
+
})
|
|
1392
|
+
)
|
|
1393
|
+
).toBe(false);
|
|
1394
|
+
});
|
|
1395
|
+
|
|
1396
|
+
it('enforces anonymous IP blocks for cookie-looking browser requests', async () => {
|
|
1397
|
+
vi.stubEnv('NODE_ENV', 'production');
|
|
1398
|
+
vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.test');
|
|
1399
|
+
vi.stubEnv('UPSTASH_REDIS_REST_TOKEN', 'token');
|
|
1400
|
+
vi.stubEnv('NEXT_PUBLIC_SUPABASE_URL', '');
|
|
1401
|
+
mocks.redis.mockReturnValue({});
|
|
1402
|
+
mocks.extractIp.mockReturnValue('1.2.3.4');
|
|
1403
|
+
mocks.isBlocked.mockResolvedValue({
|
|
1404
|
+
expiresAt: new Date(Date.now() + 30_000),
|
|
1405
|
+
reason: 'api_abuse',
|
|
1406
|
+
blockLevel: 1,
|
|
1407
|
+
blockedAt: new Date(),
|
|
1408
|
+
});
|
|
1409
|
+
|
|
1410
|
+
const { guardApiProxyRequest, clearApiProxyGuardLimiterCache } =
|
|
1411
|
+
await import('../api-proxy-guard.js');
|
|
1412
|
+
clearApiProxyGuardLimiterCache();
|
|
1413
|
+
|
|
1414
|
+
const response = await guardApiProxyRequest(
|
|
1415
|
+
makeRequest('/api/v1/workspaces/ws-1/users/database?page=1', 'GET', {
|
|
1416
|
+
cookie:
|
|
1417
|
+
'sb-resolved-kingfish-21146-auth-token.0=base64-validvalue; theme=dark',
|
|
1418
|
+
}),
|
|
1419
|
+
{ prefixBase: 'proxy:test:api' }
|
|
1420
|
+
);
|
|
1421
|
+
|
|
1422
|
+
expect(response?.status).toBe(429);
|
|
1423
|
+
expect(response?.headers.get('X-Proxy-Block-Reason')).toBe(
|
|
1424
|
+
'ip-already-blocked'
|
|
1014
1425
|
);
|
|
1015
1426
|
expect(mocks.limit).not.toHaveBeenCalled();
|
|
1016
1427
|
});
|
|
1017
1428
|
|
|
1018
|
-
it('
|
|
1429
|
+
it('shares broad anonymous read buckets across path variants', async () => {
|
|
1019
1430
|
vi.stubEnv('NODE_ENV', 'production');
|
|
1020
1431
|
vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.test');
|
|
1021
1432
|
vi.stubEnv('UPSTASH_REDIS_REST_TOKEN', 'token');
|
|
@@ -1036,13 +1447,28 @@ describe('guardApiProxyRequest', () => {
|
|
|
1036
1447
|
await guardApiProxyRequest(makeRequest('/api/v1/users/me/profile', 'GET'), {
|
|
1037
1448
|
prefixBase: 'proxy:test:api',
|
|
1038
1449
|
});
|
|
1450
|
+
await guardApiProxyRequest(
|
|
1451
|
+
makeRequest('/api/v1/users/me/nonexistent/random-nonce', 'GET'),
|
|
1452
|
+
{
|
|
1453
|
+
prefixBase: 'proxy:test:api',
|
|
1454
|
+
}
|
|
1455
|
+
);
|
|
1039
1456
|
|
|
1040
1457
|
expect(mocks.ratelimitPrefixes).toContain(
|
|
1458
|
+
'proxy:test:api:users-me:anonymous:get:minute'
|
|
1459
|
+
);
|
|
1460
|
+
expect(mocks.ratelimitPrefixes).not.toContain(
|
|
1041
1461
|
'proxy:test:api:users-me:anonymous::api:v1:users:me:profile:get:minute'
|
|
1042
1462
|
);
|
|
1463
|
+
expect(mocks.ratelimitPrefixes).not.toContain(
|
|
1464
|
+
'proxy:test:api:users-me:anonymous::api:v1:users:me:nonexistent:random-nonce:get:minute'
|
|
1465
|
+
);
|
|
1043
1466
|
});
|
|
1044
1467
|
|
|
1045
|
-
it(
|
|
1468
|
+
it.each([
|
|
1469
|
+
'/api/v1/auth/otp/send',
|
|
1470
|
+
'/api/v1/auth/mobile/send-otp',
|
|
1471
|
+
])('uses a classroom-friendly OTP send bucket for %s', async (path) => {
|
|
1046
1472
|
vi.stubEnv('NODE_ENV', 'production');
|
|
1047
1473
|
vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.test');
|
|
1048
1474
|
vi.stubEnv('UPSTASH_REDIS_REST_TOKEN', 'token');
|
|
@@ -1060,12 +1486,9 @@ describe('guardApiProxyRequest', () => {
|
|
|
1060
1486
|
await import('../api-proxy-guard.js');
|
|
1061
1487
|
clearApiProxyGuardLimiterCache();
|
|
1062
1488
|
|
|
1063
|
-
const response = await guardApiProxyRequest(
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
prefixBase: 'proxy:test:api',
|
|
1067
|
-
}
|
|
1068
|
-
);
|
|
1489
|
+
const response = await guardApiProxyRequest(makeRequest(path, 'POST'), {
|
|
1490
|
+
prefixBase: 'proxy:test:api',
|
|
1491
|
+
});
|
|
1069
1492
|
|
|
1070
1493
|
expect(response?.status).toBe(429);
|
|
1071
1494
|
expect(response?.headers.get('X-RateLimit-Limit')).toBe('30');
|
|
@@ -1275,6 +1698,59 @@ describe('guardApiProxyRequest', () => {
|
|
|
1275
1698
|
expect(response?.headers.get('X-RateLimit-Policy')).toBe('high-fanout');
|
|
1276
1699
|
});
|
|
1277
1700
|
|
|
1701
|
+
it('prepends additional route policies before default high-fanout buckets', async () => {
|
|
1702
|
+
vi.stubEnv('NODE_ENV', 'production');
|
|
1703
|
+
vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.test');
|
|
1704
|
+
vi.stubEnv('UPSTASH_REDIS_REST_TOKEN', 'token');
|
|
1705
|
+
mocks.redis.mockReturnValue({});
|
|
1706
|
+
mocks.extractIp.mockReturnValue('1.2.3.4');
|
|
1707
|
+
mocks.isBlocked.mockResolvedValue(null);
|
|
1708
|
+
mocks.limit.mockResolvedValueOnce({
|
|
1709
|
+
success: false,
|
|
1710
|
+
limit: 30,
|
|
1711
|
+
remaining: 0,
|
|
1712
|
+
reset: Date.now() + 15_000,
|
|
1713
|
+
});
|
|
1714
|
+
|
|
1715
|
+
const { guardApiProxyRequest, clearApiProxyGuardLimiterCache } =
|
|
1716
|
+
await import('../api-proxy-guard.js');
|
|
1717
|
+
clearApiProxyGuardLimiterCache();
|
|
1718
|
+
|
|
1719
|
+
const response = await guardApiProxyRequest(
|
|
1720
|
+
makeRequest('/api/v1/workspaces/ws-1/mail/send', 'POST'),
|
|
1721
|
+
{
|
|
1722
|
+
additionalRoutePolicies: [
|
|
1723
|
+
{
|
|
1724
|
+
key: 'email-rate-limit-override',
|
|
1725
|
+
matches: (req) =>
|
|
1726
|
+
/^\/api\/v1\/workspaces\/[^/]+\/mail\/send(?:\/|$)/.test(
|
|
1727
|
+
req.nextUrl.pathname
|
|
1728
|
+
),
|
|
1729
|
+
rateLimits: {
|
|
1730
|
+
get: [],
|
|
1731
|
+
mutate: [{ duration: '1 m', limit: 30, window: 'minute' }],
|
|
1732
|
+
},
|
|
1733
|
+
},
|
|
1734
|
+
],
|
|
1735
|
+
prefixBase: 'proxy:test:api',
|
|
1736
|
+
}
|
|
1737
|
+
);
|
|
1738
|
+
|
|
1739
|
+
expect(response?.status).toBe(429);
|
|
1740
|
+
expect(response?.headers.get('X-RateLimit-Limit')).toBe('30');
|
|
1741
|
+
expect(response?.headers.get('X-RateLimit-Policy')).toBe(
|
|
1742
|
+
'email-rate-limit-override'
|
|
1743
|
+
);
|
|
1744
|
+
expect(mocks.ratelimitConfigs).toContainEqual({
|
|
1745
|
+
limit: 30,
|
|
1746
|
+
window: '1 m',
|
|
1747
|
+
});
|
|
1748
|
+
expect(mocks.ratelimitConfigs).not.toContainEqual({
|
|
1749
|
+
limit: 2,
|
|
1750
|
+
window: '1 m',
|
|
1751
|
+
});
|
|
1752
|
+
});
|
|
1753
|
+
|
|
1278
1754
|
it('uses relaxed buckets for task description persistence routes', async () => {
|
|
1279
1755
|
vi.stubEnv('NODE_ENV', 'production');
|
|
1280
1756
|
vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.test');
|
|
@@ -1487,6 +1963,448 @@ describe('guardApiProxyRequest', () => {
|
|
|
1487
1963
|
expect(mocks.validateEmoji).toHaveBeenCalledTimes(1);
|
|
1488
1964
|
});
|
|
1489
1965
|
|
|
1966
|
+
it('gives a trusted session its own per-session bucket with scaled read limits', async () => {
|
|
1967
|
+
vi.stubEnv('NODE_ENV', 'production');
|
|
1968
|
+
vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.test');
|
|
1969
|
+
vi.stubEnv('UPSTASH_REDIS_REST_TOKEN', 'token');
|
|
1970
|
+
vi.stubEnv('NEXT_PUBLIC_SUPABASE_URL', '');
|
|
1971
|
+
mocks.redis.mockReturnValue({});
|
|
1972
|
+
mocks.extractIp.mockReturnValue('1.2.3.4');
|
|
1973
|
+
mocks.isBlocked.mockResolvedValue(null);
|
|
1974
|
+
// Trust the session subject key (account trust → per-session keying).
|
|
1975
|
+
mocks.getTrustEntries.mockImplementation((keys: string[]) => {
|
|
1976
|
+
const map = new Map<string, { m: number }>();
|
|
1977
|
+
const sessionKey = keys.find((key) => key.startsWith('session:'));
|
|
1978
|
+
if (sessionKey) {
|
|
1979
|
+
map.set(sessionKey, { m: 3 });
|
|
1980
|
+
}
|
|
1981
|
+
return Promise.resolve(map);
|
|
1982
|
+
});
|
|
1983
|
+
mocks.limit.mockResolvedValueOnce({
|
|
1984
|
+
success: false,
|
|
1985
|
+
limit: 1800,
|
|
1986
|
+
remaining: 0,
|
|
1987
|
+
reset: Date.now() + 15_000,
|
|
1988
|
+
});
|
|
1989
|
+
|
|
1990
|
+
const { guardApiProxyRequest, clearApiProxyGuardLimiterCache } =
|
|
1991
|
+
await import('../api-proxy-guard.js');
|
|
1992
|
+
clearApiProxyGuardLimiterCache();
|
|
1993
|
+
|
|
1994
|
+
const response = await guardApiProxyRequest(
|
|
1995
|
+
makeRequest('/api/v1/workspaces/ws-1/tasks?boardId=board-1', 'GET', {
|
|
1996
|
+
cookie:
|
|
1997
|
+
'sb-resolved-kingfish-21146-auth-token.0=base64-validvalue; theme=dark',
|
|
1998
|
+
}),
|
|
1999
|
+
{ prefixBase: 'proxy:test:api' }
|
|
2000
|
+
);
|
|
2001
|
+
|
|
2002
|
+
expect(response?.status).toBe(429);
|
|
2003
|
+
expect(response?.headers.get('X-RateLimit-Policy')).toBe('task-board-read');
|
|
2004
|
+
// Base task-board read minute limit (600) scaled by the trust tier (3).
|
|
2005
|
+
expect(mocks.ratelimitConfigs).toContainEqual({
|
|
2006
|
+
limit: 1800,
|
|
2007
|
+
window: '1 m',
|
|
2008
|
+
});
|
|
2009
|
+
// Trusted traffic gets an authenticated, tier-suffixed bucket.
|
|
2010
|
+
expect(mocks.ratelimitPrefixes).toContain(
|
|
2011
|
+
'proxy:test:api:task-board-read:authenticated:t3:get:minute'
|
|
2012
|
+
);
|
|
2013
|
+
// The limiter is keyed by the per-session subject key, not the shared IP.
|
|
2014
|
+
const limiterId = mocks.limit.mock.calls[0]?.[0] as string;
|
|
2015
|
+
expect(limiterId.startsWith('session:')).toBe(true);
|
|
2016
|
+
});
|
|
2017
|
+
|
|
2018
|
+
it('keeps trusted-session mutation limits flat while using the session bucket', async () => {
|
|
2019
|
+
vi.stubEnv('NODE_ENV', 'production');
|
|
2020
|
+
vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.test');
|
|
2021
|
+
vi.stubEnv('UPSTASH_REDIS_REST_TOKEN', 'token');
|
|
2022
|
+
vi.stubEnv('NEXT_PUBLIC_SUPABASE_URL', '');
|
|
2023
|
+
mocks.redis.mockReturnValue({});
|
|
2024
|
+
mocks.extractIp.mockReturnValue('1.2.3.4');
|
|
2025
|
+
mocks.isBlocked.mockResolvedValue(null);
|
|
2026
|
+
mocks.getTrustEntries.mockImplementation((keys: string[]) => {
|
|
2027
|
+
const map = new Map<string, { m: number }>();
|
|
2028
|
+
const sessionKey = keys.find((key) => key.startsWith('session:'));
|
|
2029
|
+
if (sessionKey) {
|
|
2030
|
+
map.set(sessionKey, { m: 3 });
|
|
2031
|
+
}
|
|
2032
|
+
return Promise.resolve(map);
|
|
2033
|
+
});
|
|
2034
|
+
mocks.limit.mockResolvedValueOnce({
|
|
2035
|
+
success: false,
|
|
2036
|
+
limit: 60,
|
|
2037
|
+
remaining: 0,
|
|
2038
|
+
reset: Date.now() + 15_000,
|
|
2039
|
+
});
|
|
2040
|
+
|
|
2041
|
+
const { guardApiProxyRequest, clearApiProxyGuardLimiterCache } =
|
|
2042
|
+
await import('../api-proxy-guard.js');
|
|
2043
|
+
clearApiProxyGuardLimiterCache();
|
|
2044
|
+
|
|
2045
|
+
const response = await guardApiProxyRequest(
|
|
2046
|
+
makeRequest(
|
|
2047
|
+
'/api/v1/workspaces/071e0fc7-9aa8-42d8-92e5-cc9b3aeec2f1/calendar/events/68e18a3a-c527-421e-8ac3-f8b846175622',
|
|
2048
|
+
'DELETE',
|
|
2049
|
+
{
|
|
2050
|
+
cookie:
|
|
2051
|
+
'sb-resolved-kingfish-21146-auth-token.0=base64-validvalue; theme=dark',
|
|
2052
|
+
}
|
|
2053
|
+
),
|
|
2054
|
+
{ prefixBase: 'proxy:test:api' }
|
|
2055
|
+
);
|
|
2056
|
+
|
|
2057
|
+
expect(response?.status).toBe(429);
|
|
2058
|
+
expect(response?.headers.get('X-RateLimit-Caller-Class')).toBe(
|
|
2059
|
+
'authenticated'
|
|
2060
|
+
);
|
|
2061
|
+
expect(response?.headers.get('X-RateLimit-Limit')).toBe('60');
|
|
2062
|
+
expect(mocks.ratelimitConfigs).toContainEqual({
|
|
2063
|
+
limit: 60,
|
|
2064
|
+
window: '1 m',
|
|
2065
|
+
});
|
|
2066
|
+
expect(mocks.ratelimitPrefixes).toContain(
|
|
2067
|
+
'proxy:test:api:default:authenticated:mutate:minute'
|
|
2068
|
+
);
|
|
2069
|
+
expect(mocks.ratelimitPrefixes).not.toContain(
|
|
2070
|
+
'proxy:test:api:default:authenticated:t3:mutate:minute'
|
|
2071
|
+
);
|
|
2072
|
+
const limiterId = mocks.limit.mock.calls[0]?.[0] as string;
|
|
2073
|
+
expect(limiterId.startsWith('session:')).toBe(true);
|
|
2074
|
+
});
|
|
2075
|
+
|
|
2076
|
+
it('uplifts a trusted location per-IP without per-session keying', async () => {
|
|
2077
|
+
vi.stubEnv('NODE_ENV', 'production');
|
|
2078
|
+
vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.test');
|
|
2079
|
+
vi.stubEnv('UPSTASH_REDIS_REST_TOKEN', 'token');
|
|
2080
|
+
mocks.redis.mockReturnValue({});
|
|
2081
|
+
mocks.extractIp.mockReturnValue('1.2.3.4');
|
|
2082
|
+
mocks.isBlocked.mockResolvedValue(null);
|
|
2083
|
+
// Trust the location (cidr) subject key only — no session cookie present.
|
|
2084
|
+
mocks.getTrustEntries.mockImplementation((keys: string[]) => {
|
|
2085
|
+
const map = new Map<string, { m: number }>();
|
|
2086
|
+
const cidrKey = keys.find((key) => key.startsWith('cidr:'));
|
|
2087
|
+
if (cidrKey) {
|
|
2088
|
+
map.set(cidrKey, { m: 2 });
|
|
2089
|
+
}
|
|
2090
|
+
return Promise.resolve(map);
|
|
2091
|
+
});
|
|
2092
|
+
mocks.limit.mockResolvedValueOnce({
|
|
2093
|
+
success: false,
|
|
2094
|
+
limit: 1200,
|
|
2095
|
+
remaining: 0,
|
|
2096
|
+
reset: Date.now() + 15_000,
|
|
2097
|
+
});
|
|
2098
|
+
|
|
2099
|
+
const { guardApiProxyRequest, clearApiProxyGuardLimiterCache } =
|
|
2100
|
+
await import('../api-proxy-guard.js');
|
|
2101
|
+
clearApiProxyGuardLimiterCache();
|
|
2102
|
+
|
|
2103
|
+
const response = await guardApiProxyRequest(
|
|
2104
|
+
makeRequest('/api/v1/workspaces/ws-1/tasks?boardId=board-1', 'GET'),
|
|
2105
|
+
{ prefixBase: 'proxy:test:api' }
|
|
2106
|
+
);
|
|
2107
|
+
|
|
2108
|
+
expect(response?.status).toBe(429);
|
|
2109
|
+
expect(mocks.ratelimitConfigs).toContainEqual({
|
|
2110
|
+
limit: 1200,
|
|
2111
|
+
window: '1 m',
|
|
2112
|
+
});
|
|
2113
|
+
expect(mocks.ratelimitPrefixes).toContain(
|
|
2114
|
+
'proxy:test:api:task-board-read:anonymous:t2:get:minute'
|
|
2115
|
+
);
|
|
2116
|
+
// Location trust keeps the shared per-IP bucket (whole team benefits).
|
|
2117
|
+
expect(mocks.limit).toHaveBeenCalledWith('ip:1.2.3.4');
|
|
2118
|
+
});
|
|
2119
|
+
|
|
2120
|
+
it('does not apply trusted-location read uplift to mutations', async () => {
|
|
2121
|
+
vi.stubEnv('NODE_ENV', 'production');
|
|
2122
|
+
vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.test');
|
|
2123
|
+
vi.stubEnv('UPSTASH_REDIS_REST_TOKEN', 'token');
|
|
2124
|
+
mocks.redis.mockReturnValue({});
|
|
2125
|
+
mocks.extractIp.mockReturnValue('1.2.3.4');
|
|
2126
|
+
mocks.isBlocked.mockResolvedValue(null);
|
|
2127
|
+
const receivedKeys: string[] = [];
|
|
2128
|
+
mocks.getTrustEntries.mockImplementation((keys: string[]) => {
|
|
2129
|
+
receivedKeys.push(...keys);
|
|
2130
|
+
const map = new Map<string, { m: number }>();
|
|
2131
|
+
const cidrKey = keys.find((key) => key.startsWith('cidr:'));
|
|
2132
|
+
if (cidrKey) {
|
|
2133
|
+
map.set(cidrKey, { m: 2 });
|
|
2134
|
+
}
|
|
2135
|
+
return Promise.resolve(map);
|
|
2136
|
+
});
|
|
2137
|
+
mocks.limit.mockResolvedValueOnce({
|
|
2138
|
+
success: false,
|
|
2139
|
+
limit: 30,
|
|
2140
|
+
remaining: 0,
|
|
2141
|
+
reset: Date.now() + 15_000,
|
|
2142
|
+
});
|
|
2143
|
+
|
|
2144
|
+
const { guardApiProxyRequest, clearApiProxyGuardLimiterCache } =
|
|
2145
|
+
await import('../api-proxy-guard.js');
|
|
2146
|
+
clearApiProxyGuardLimiterCache();
|
|
2147
|
+
|
|
2148
|
+
const response = await guardApiProxyRequest(
|
|
2149
|
+
makeRequest('/api/v1/workspaces/ws-1/tasks', 'POST'),
|
|
2150
|
+
{ prefixBase: 'proxy:test:api' }
|
|
2151
|
+
);
|
|
2152
|
+
|
|
2153
|
+
expect(response?.status).toBe(429);
|
|
2154
|
+
expect(response?.headers.get('X-RateLimit-Caller-Class')).toBe('anonymous');
|
|
2155
|
+
expect(response?.headers.get('X-RateLimit-Limit')).toBe('30');
|
|
2156
|
+
expect(mocks.ratelimitConfigs).toContainEqual({
|
|
2157
|
+
limit: 30,
|
|
2158
|
+
window: '1 m',
|
|
2159
|
+
});
|
|
2160
|
+
expect(mocks.ratelimitPrefixes).toContain(
|
|
2161
|
+
'proxy:test:api:default:anonymous:mutate:minute'
|
|
2162
|
+
);
|
|
2163
|
+
expect(mocks.ratelimitPrefixes).not.toContain(
|
|
2164
|
+
'proxy:test:api:default:anonymous:t2:mutate:minute'
|
|
2165
|
+
);
|
|
2166
|
+
expect(receivedKeys.some((key) => key.startsWith('cidr:'))).toBe(false);
|
|
2167
|
+
expect(mocks.limit).toHaveBeenCalledWith('ip:1.2.3.4');
|
|
2168
|
+
});
|
|
2169
|
+
|
|
2170
|
+
it('keeps a trusted location on per-IP keying even with an untrusted session cookie', async () => {
|
|
2171
|
+
vi.stubEnv('NODE_ENV', 'production');
|
|
2172
|
+
vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.test');
|
|
2173
|
+
vi.stubEnv('UPSTASH_REDIS_REST_TOKEN', 'token');
|
|
2174
|
+
vi.stubEnv('NEXT_PUBLIC_SUPABASE_URL', '');
|
|
2175
|
+
mocks.redis.mockReturnValue({});
|
|
2176
|
+
mocks.extractIp.mockReturnValue('1.2.3.4');
|
|
2177
|
+
mocks.isBlocked.mockResolvedValue(null);
|
|
2178
|
+
// Only the cidr is trusted; the session key is absent from the cache.
|
|
2179
|
+
mocks.getTrustEntries.mockImplementation((keys: string[]) => {
|
|
2180
|
+
const map = new Map<string, { m: number }>();
|
|
2181
|
+
const cidrKey = keys.find((key) => key.startsWith('cidr:'));
|
|
2182
|
+
if (cidrKey) {
|
|
2183
|
+
map.set(cidrKey, { m: 2 });
|
|
2184
|
+
}
|
|
2185
|
+
return Promise.resolve(map);
|
|
2186
|
+
});
|
|
2187
|
+
mocks.limit.mockResolvedValueOnce({
|
|
2188
|
+
success: false,
|
|
2189
|
+
limit: 1200,
|
|
2190
|
+
remaining: 0,
|
|
2191
|
+
reset: Date.now() + 15_000,
|
|
2192
|
+
});
|
|
2193
|
+
|
|
2194
|
+
const { guardApiProxyRequest, clearApiProxyGuardLimiterCache } =
|
|
2195
|
+
await import('../api-proxy-guard.js');
|
|
2196
|
+
clearApiProxyGuardLimiterCache();
|
|
2197
|
+
|
|
2198
|
+
await guardApiProxyRequest(
|
|
2199
|
+
makeRequest('/api/v1/workspaces/ws-1/tasks?boardId=board-1', 'GET', {
|
|
2200
|
+
cookie:
|
|
2201
|
+
'sb-resolved-kingfish-21146-auth-token.0=base64-validvalue; theme=dark',
|
|
2202
|
+
}),
|
|
2203
|
+
{ prefixBase: 'proxy:test:api' }
|
|
2204
|
+
);
|
|
2205
|
+
|
|
2206
|
+
// An untrusted session must not escape the per-IP bucket.
|
|
2207
|
+
expect(mocks.limit).toHaveBeenCalledWith('ip:1.2.3.4');
|
|
2208
|
+
});
|
|
2209
|
+
|
|
2210
|
+
it('keeps untrusted reads on the legacy per-IP bucket without trust suffix', async () => {
|
|
2211
|
+
vi.stubEnv('NODE_ENV', 'production');
|
|
2212
|
+
vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.test');
|
|
2213
|
+
vi.stubEnv('UPSTASH_REDIS_REST_TOKEN', 'token');
|
|
2214
|
+
mocks.redis.mockReturnValue({});
|
|
2215
|
+
mocks.extractIp.mockReturnValue('1.2.3.4');
|
|
2216
|
+
mocks.isBlocked.mockResolvedValue(null);
|
|
2217
|
+
// Default mock: empty trust map (forged or simply untrusted).
|
|
2218
|
+
mocks.limit.mockResolvedValueOnce({
|
|
2219
|
+
success: false,
|
|
2220
|
+
limit: 600,
|
|
2221
|
+
remaining: 0,
|
|
2222
|
+
reset: Date.now() + 15_000,
|
|
2223
|
+
});
|
|
2224
|
+
|
|
2225
|
+
const { guardApiProxyRequest, clearApiProxyGuardLimiterCache } =
|
|
2226
|
+
await import('../api-proxy-guard.js');
|
|
2227
|
+
clearApiProxyGuardLimiterCache();
|
|
2228
|
+
|
|
2229
|
+
const response = await guardApiProxyRequest(
|
|
2230
|
+
makeRequest('/api/v1/workspaces/ws-1/tasks?boardId=board-1', 'GET', {
|
|
2231
|
+
cookie:
|
|
2232
|
+
'sb-resolved-kingfish-21146-auth-token.0=forged-value; theme=dark',
|
|
2233
|
+
}),
|
|
2234
|
+
{ prefixBase: 'proxy:test:api' }
|
|
2235
|
+
);
|
|
2236
|
+
|
|
2237
|
+
expect(response?.status).toBe(429);
|
|
2238
|
+
expect(mocks.ratelimitConfigs).toContainEqual({
|
|
2239
|
+
limit: 600,
|
|
2240
|
+
window: '1 m',
|
|
2241
|
+
});
|
|
2242
|
+
expect(mocks.ratelimitPrefixes).toContain(
|
|
2243
|
+
'proxy:test:api:task-board-read:anonymous:get:minute'
|
|
2244
|
+
);
|
|
2245
|
+
expect(mocks.limit).toHaveBeenCalledWith('ip:1.2.3.4');
|
|
2246
|
+
});
|
|
2247
|
+
|
|
2248
|
+
it('applies an absolute read limit from a location rule', async () => {
|
|
2249
|
+
vi.stubEnv('NODE_ENV', 'production');
|
|
2250
|
+
vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.test');
|
|
2251
|
+
vi.stubEnv('UPSTASH_REDIS_REST_TOKEN', 'token');
|
|
2252
|
+
mocks.redis.mockReturnValue({});
|
|
2253
|
+
mocks.extractIp.mockReturnValue('1.2.3.4');
|
|
2254
|
+
mocks.isBlocked.mockResolvedValue(null);
|
|
2255
|
+
mocks.getTrustEntries.mockImplementation((keys: string[]) => {
|
|
2256
|
+
const map = new Map<string, unknown>();
|
|
2257
|
+
const cidrKey = keys.find((key) => key.startsWith('cidr:'));
|
|
2258
|
+
if (cidrKey) {
|
|
2259
|
+
map.set(cidrKey, { abs: { minute: 50 }, m: 1, mode: 'absolute' });
|
|
2260
|
+
}
|
|
2261
|
+
return Promise.resolve(map);
|
|
2262
|
+
});
|
|
2263
|
+
mocks.limit.mockResolvedValueOnce({
|
|
2264
|
+
success: false,
|
|
2265
|
+
limit: 50,
|
|
2266
|
+
remaining: 0,
|
|
2267
|
+
reset: Date.now() + 15_000,
|
|
2268
|
+
});
|
|
2269
|
+
|
|
2270
|
+
const { guardApiProxyRequest, clearApiProxyGuardLimiterCache } =
|
|
2271
|
+
await import('../api-proxy-guard.js');
|
|
2272
|
+
clearApiProxyGuardLimiterCache();
|
|
2273
|
+
|
|
2274
|
+
const response = await guardApiProxyRequest(
|
|
2275
|
+
makeRequest('/api/v1/workspaces/ws-1/tasks?boardId=board-1', 'GET'),
|
|
2276
|
+
{ prefixBase: 'proxy:test:api' }
|
|
2277
|
+
);
|
|
2278
|
+
|
|
2279
|
+
expect(response?.status).toBe(429);
|
|
2280
|
+
// Absolute exactly sets the minute window (even below the base 600).
|
|
2281
|
+
expect(mocks.ratelimitConfigs).toContainEqual({ limit: 50, window: '1 m' });
|
|
2282
|
+
expect(mocks.ratelimitPrefixes).toContain(
|
|
2283
|
+
'proxy:test:api:task-board-read:anonymous:abs-50-x-x:get:minute'
|
|
2284
|
+
);
|
|
2285
|
+
expect(mocks.limit).toHaveBeenCalledWith('ip:1.2.3.4');
|
|
2286
|
+
});
|
|
2287
|
+
|
|
2288
|
+
it('removes the read cap for an unlimited rule', async () => {
|
|
2289
|
+
vi.stubEnv('NODE_ENV', 'production');
|
|
2290
|
+
vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.test');
|
|
2291
|
+
vi.stubEnv('UPSTASH_REDIS_REST_TOKEN', 'token');
|
|
2292
|
+
mocks.redis.mockReturnValue({});
|
|
2293
|
+
mocks.extractIp.mockReturnValue('1.2.3.4');
|
|
2294
|
+
mocks.isBlocked.mockResolvedValue(null);
|
|
2295
|
+
mocks.getTrustEntries.mockImplementation((keys: string[]) => {
|
|
2296
|
+
const map = new Map<string, unknown>();
|
|
2297
|
+
const cidrKey = keys.find((key) => key.startsWith('cidr:'));
|
|
2298
|
+
if (cidrKey) {
|
|
2299
|
+
map.set(cidrKey, { m: 1, mode: 'unlimited' });
|
|
2300
|
+
}
|
|
2301
|
+
return Promise.resolve(map);
|
|
2302
|
+
});
|
|
2303
|
+
mocks.validateEmoji.mockResolvedValue(null);
|
|
2304
|
+
|
|
2305
|
+
const { guardApiProxyRequest, clearApiProxyGuardLimiterCache } =
|
|
2306
|
+
await import('../api-proxy-guard.js');
|
|
2307
|
+
clearApiProxyGuardLimiterCache();
|
|
2308
|
+
|
|
2309
|
+
const response = await guardApiProxyRequest(
|
|
2310
|
+
makeRequest('/api/v1/workspaces/ws-1/tasks?boardId=board-1', 'GET'),
|
|
2311
|
+
{ prefixBase: 'proxy:test:api' }
|
|
2312
|
+
);
|
|
2313
|
+
|
|
2314
|
+
// No read limiter is constructed → request passes the rate-limit stage.
|
|
2315
|
+
expect(response).toBeNull();
|
|
2316
|
+
expect(mocks.limit).not.toHaveBeenCalled();
|
|
2317
|
+
});
|
|
2318
|
+
|
|
2319
|
+
it('uplifts reads for a trusted workspace parsed from the path', async () => {
|
|
2320
|
+
vi.stubEnv('NODE_ENV', 'production');
|
|
2321
|
+
vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.test');
|
|
2322
|
+
vi.stubEnv('UPSTASH_REDIS_REST_TOKEN', 'token');
|
|
2323
|
+
mocks.redis.mockReturnValue({});
|
|
2324
|
+
mocks.extractIp.mockReturnValue('1.2.3.4');
|
|
2325
|
+
mocks.isBlocked.mockResolvedValue(null);
|
|
2326
|
+
const receivedKeys: string[] = [];
|
|
2327
|
+
mocks.getTrustEntries.mockImplementation((keys: string[]) => {
|
|
2328
|
+
receivedKeys.push(...keys);
|
|
2329
|
+
const map = new Map<string, unknown>();
|
|
2330
|
+
const workspaceKey = keys.find((key) => key.startsWith('workspace:'));
|
|
2331
|
+
if (workspaceKey) {
|
|
2332
|
+
map.set(workspaceKey, { m: 5 });
|
|
2333
|
+
}
|
|
2334
|
+
return Promise.resolve(map);
|
|
2335
|
+
});
|
|
2336
|
+
mocks.limit.mockResolvedValueOnce({
|
|
2337
|
+
success: false,
|
|
2338
|
+
limit: 3000,
|
|
2339
|
+
remaining: 0,
|
|
2340
|
+
reset: Date.now() + 15_000,
|
|
2341
|
+
});
|
|
2342
|
+
|
|
2343
|
+
const { guardApiProxyRequest, clearApiProxyGuardLimiterCache } =
|
|
2344
|
+
await import('../api-proxy-guard.js');
|
|
2345
|
+
clearApiProxyGuardLimiterCache();
|
|
2346
|
+
|
|
2347
|
+
const wsId = '33333333-3333-4333-8333-333333333333';
|
|
2348
|
+
const response = await guardApiProxyRequest(
|
|
2349
|
+
makeRequest(`/api/v1/workspaces/${wsId}/tasks?boardId=board-1`, 'GET'),
|
|
2350
|
+
{ prefixBase: 'proxy:test:api' }
|
|
2351
|
+
);
|
|
2352
|
+
|
|
2353
|
+
expect(response?.status).toBe(429);
|
|
2354
|
+
expect(receivedKeys).toContain(`workspace:${wsId}`);
|
|
2355
|
+
// Base task-board minute (600) scaled by the workspace tier (5).
|
|
2356
|
+
expect(mocks.ratelimitConfigs).toContainEqual({
|
|
2357
|
+
limit: 3000,
|
|
2358
|
+
window: '1 m',
|
|
2359
|
+
});
|
|
2360
|
+
expect(mocks.ratelimitPrefixes).toContain(
|
|
2361
|
+
'proxy:test:api:task-board-read:anonymous:t5:get:minute'
|
|
2362
|
+
);
|
|
2363
|
+
});
|
|
2364
|
+
|
|
2365
|
+
it('derives the session subject key the same way as server-side reputation', async () => {
|
|
2366
|
+
vi.stubEnv('NODE_ENV', 'production');
|
|
2367
|
+
vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.test');
|
|
2368
|
+
vi.stubEnv('UPSTASH_REDIS_REST_TOKEN', 'token');
|
|
2369
|
+
vi.stubEnv('NEXT_PUBLIC_SUPABASE_URL', '');
|
|
2370
|
+
mocks.redis.mockReturnValue({});
|
|
2371
|
+
mocks.extractIp.mockReturnValue('1.2.3.4');
|
|
2372
|
+
mocks.isBlocked.mockResolvedValue(null);
|
|
2373
|
+
const receivedKeys: string[] = [];
|
|
2374
|
+
mocks.getTrustEntries.mockImplementation((keys: string[]) => {
|
|
2375
|
+
receivedKeys.push(...keys);
|
|
2376
|
+
return Promise.resolve(new Map<string, { m: number }>());
|
|
2377
|
+
});
|
|
2378
|
+
mocks.limit.mockResolvedValue({
|
|
2379
|
+
success: true,
|
|
2380
|
+
limit: 600,
|
|
2381
|
+
remaining: 599,
|
|
2382
|
+
reset: Date.now() + 15_000,
|
|
2383
|
+
});
|
|
2384
|
+
mocks.validateEmoji.mockResolvedValue(null);
|
|
2385
|
+
|
|
2386
|
+
const { guardApiProxyRequest, clearApiProxyGuardLimiterCache } =
|
|
2387
|
+
await import('../api-proxy-guard.js');
|
|
2388
|
+
clearApiProxyGuardLimiterCache();
|
|
2389
|
+
|
|
2390
|
+
const cookieName = 'sb-resolved-kingfish-21146-auth-token.0';
|
|
2391
|
+
const cookieValue = 'base64-validvalue';
|
|
2392
|
+
await guardApiProxyRequest(
|
|
2393
|
+
makeRequest('/api/v1/workspaces/ws-1/tasks?boardId=board-1', 'GET', {
|
|
2394
|
+
cookie: `${cookieName}=${cookieValue}; theme=dark`,
|
|
2395
|
+
}),
|
|
2396
|
+
{ prefixBase: 'proxy:test:api' }
|
|
2397
|
+
);
|
|
2398
|
+
|
|
2399
|
+
const { createHash } = await import('node:crypto');
|
|
2400
|
+
const expected = `session:${createHash('sha256')
|
|
2401
|
+
.update(`${cookieName}:${cookieValue}`)
|
|
2402
|
+
.digest('hex')
|
|
2403
|
+
.slice(0, 24)}`;
|
|
2404
|
+
|
|
2405
|
+
expect(receivedKeys).toContain(expected);
|
|
2406
|
+
});
|
|
2407
|
+
|
|
1490
2408
|
it('skips text-bomb validation for whiteboard snapshots on the whiteboard save route', async () => {
|
|
1491
2409
|
vi.stubEnv('NODE_ENV', 'production');
|
|
1492
2410
|
vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.test');
|