@tuturuuu/utils 0.8.0 → 0.10.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 +21 -0
- package/biome.json +1 -1
- package/package.json +17 -15
- package/src/__tests__/api-proxy-guard.test.ts +728 -71
- package/src/__tests__/app-url.test.ts +2 -0
- package/src/__tests__/task-description-codec.test.ts +110 -0
- package/src/abuse-protection/__tests__/backend-rate-limit.test.ts +10 -45
- package/src/abuse-protection/__tests__/edge-trust.test.ts +99 -0
- package/src/abuse-protection/__tests__/index.test.ts +136 -0
- package/src/abuse-protection/backend-rate-limit.ts +5 -4
- package/src/abuse-protection/edge-trust.ts +141 -6
- package/src/abuse-protection/index.ts +73 -40
- package/src/api-proxy-guard.ts +202 -43
- 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 +24 -0
- package/src/platform-release.test.ts +1 -1
- package/src/platform-release.ts +1 -1
- package/src/portless.ts +2 -0
- package/src/task-description-codec.ts +457 -0
- package/src/task-helper/task-operations.ts +3 -0
- package/src/yjs-task-description.ts +13 -21
|
@@ -10,6 +10,7 @@ const mocks = vi.hoisted(() => ({
|
|
|
10
10
|
isBlocked: vi.fn(),
|
|
11
11
|
validateEmoji: vi.fn(),
|
|
12
12
|
getTrustEntries: vi.fn(),
|
|
13
|
+
hasAppealRelief: vi.fn(),
|
|
13
14
|
}));
|
|
14
15
|
|
|
15
16
|
vi.mock('@upstash/ratelimit', () => {
|
|
@@ -53,6 +54,8 @@ vi.mock('../abuse-protection/edge', () => ({
|
|
|
53
54
|
|
|
54
55
|
vi.mock('../abuse-protection/edge-trust', () => ({
|
|
55
56
|
getCachedTrustEntries: (keys: string[]) => mocks.getTrustEntries(keys),
|
|
57
|
+
hasCachedIpBlockAppealRelief: (sessionKey: string, ipKey: string) =>
|
|
58
|
+
mocks.hasAppealRelief(sessionKey, ipKey),
|
|
56
59
|
}));
|
|
57
60
|
|
|
58
61
|
vi.mock('../request-emoji-limit', () => ({
|
|
@@ -104,8 +107,10 @@ describe('guardApiProxyRequest', () => {
|
|
|
104
107
|
mocks.isBlocked.mockReset();
|
|
105
108
|
mocks.validateEmoji.mockReset();
|
|
106
109
|
mocks.getTrustEntries.mockReset();
|
|
110
|
+
mocks.hasAppealRelief.mockReset();
|
|
107
111
|
// Default: no cached trust (untrusted). Tests opt in by overriding.
|
|
108
112
|
mocks.getTrustEntries.mockResolvedValue(new Map<string, { m: number }>());
|
|
113
|
+
mocks.hasAppealRelief.mockResolvedValue(false);
|
|
109
114
|
});
|
|
110
115
|
|
|
111
116
|
afterEach(() => {
|
|
@@ -172,6 +177,147 @@ describe('guardApiProxyRequest', () => {
|
|
|
172
177
|
expect(mocks.limit).not.toHaveBeenCalled();
|
|
173
178
|
});
|
|
174
179
|
|
|
180
|
+
it('allows a blocked IP to submit the rate-limit appeal endpoint only', async () => {
|
|
181
|
+
vi.stubEnv('NODE_ENV', 'production');
|
|
182
|
+
vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.test');
|
|
183
|
+
vi.stubEnv('UPSTASH_REDIS_REST_TOKEN', 'token');
|
|
184
|
+
mocks.redis.mockReturnValue({});
|
|
185
|
+
mocks.extractIp.mockReturnValue('1.2.3.4');
|
|
186
|
+
mocks.isBlocked.mockResolvedValue({
|
|
187
|
+
expiresAt: new Date(Date.now() + 30_000),
|
|
188
|
+
reason: 'abuse',
|
|
189
|
+
blockLevel: 'temporary',
|
|
190
|
+
});
|
|
191
|
+
mocks.limit
|
|
192
|
+
.mockResolvedValueOnce({
|
|
193
|
+
success: true,
|
|
194
|
+
limit: 1,
|
|
195
|
+
remaining: 0,
|
|
196
|
+
reset: Date.now() + 15_000,
|
|
197
|
+
})
|
|
198
|
+
.mockResolvedValueOnce({
|
|
199
|
+
success: true,
|
|
200
|
+
limit: 3,
|
|
201
|
+
remaining: 2,
|
|
202
|
+
reset: Date.now() + 15_000,
|
|
203
|
+
})
|
|
204
|
+
.mockResolvedValueOnce({
|
|
205
|
+
success: true,
|
|
206
|
+
limit: 10,
|
|
207
|
+
remaining: 9,
|
|
208
|
+
reset: Date.now() + 15_000,
|
|
209
|
+
});
|
|
210
|
+
mocks.validateEmoji.mockResolvedValue(null);
|
|
211
|
+
|
|
212
|
+
const { guardApiProxyRequest, clearApiProxyGuardLimiterCache } =
|
|
213
|
+
await import('../api-proxy-guard.js');
|
|
214
|
+
clearApiProxyGuardLimiterCache();
|
|
215
|
+
|
|
216
|
+
const response = await guardApiProxyRequest(
|
|
217
|
+
makeRequest('/api/v1/rate-limit-appeals', 'POST', {
|
|
218
|
+
cookie: 'tuturuuu_app_session=session-value',
|
|
219
|
+
}),
|
|
220
|
+
{
|
|
221
|
+
prefixBase: 'proxy:test:api',
|
|
222
|
+
}
|
|
223
|
+
);
|
|
224
|
+
|
|
225
|
+
expect(response).toBeNull();
|
|
226
|
+
expect(mocks.hasAppealRelief).not.toHaveBeenCalled();
|
|
227
|
+
expect(mocks.limit).toHaveBeenCalledTimes(3);
|
|
228
|
+
expect(mocks.ratelimitConfigs).toContainEqual({
|
|
229
|
+
limit: 1,
|
|
230
|
+
window: '1 m',
|
|
231
|
+
});
|
|
232
|
+
expect(mocks.ratelimitPrefixes).toContain(
|
|
233
|
+
'proxy:test:api:rate-limit-appeal:anonymous:mutate:minute'
|
|
234
|
+
);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it('allows a blocked IP through normal APIs only with matching temporary session relief', async () => {
|
|
238
|
+
vi.stubEnv('NODE_ENV', 'production');
|
|
239
|
+
vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.test');
|
|
240
|
+
vi.stubEnv('UPSTASH_REDIS_REST_TOKEN', 'token');
|
|
241
|
+
mocks.redis.mockReturnValue({});
|
|
242
|
+
mocks.extractIp.mockReturnValue('1.2.3.4');
|
|
243
|
+
mocks.isBlocked.mockResolvedValue({
|
|
244
|
+
expiresAt: new Date(Date.now() + 30_000),
|
|
245
|
+
reason: 'abuse',
|
|
246
|
+
blockLevel: 'temporary',
|
|
247
|
+
});
|
|
248
|
+
mocks.hasAppealRelief.mockResolvedValueOnce(true);
|
|
249
|
+
mocks.limit
|
|
250
|
+
.mockResolvedValueOnce({
|
|
251
|
+
success: true,
|
|
252
|
+
limit: 30,
|
|
253
|
+
remaining: 29,
|
|
254
|
+
reset: Date.now() + 15_000,
|
|
255
|
+
})
|
|
256
|
+
.mockResolvedValueOnce({
|
|
257
|
+
success: true,
|
|
258
|
+
limit: 120,
|
|
259
|
+
remaining: 119,
|
|
260
|
+
reset: Date.now() + 15_000,
|
|
261
|
+
})
|
|
262
|
+
.mockResolvedValueOnce({
|
|
263
|
+
success: true,
|
|
264
|
+
limit: 600,
|
|
265
|
+
remaining: 599,
|
|
266
|
+
reset: Date.now() + 15_000,
|
|
267
|
+
});
|
|
268
|
+
mocks.validateEmoji.mockResolvedValue(null);
|
|
269
|
+
|
|
270
|
+
const { guardApiProxyRequest, clearApiProxyGuardLimiterCache } =
|
|
271
|
+
await import('../api-proxy-guard.js');
|
|
272
|
+
clearApiProxyGuardLimiterCache();
|
|
273
|
+
|
|
274
|
+
const response = await guardApiProxyRequest(
|
|
275
|
+
makeRequest('/api/test', 'POST', {
|
|
276
|
+
cookie: 'tuturuuu_app_session=session-value',
|
|
277
|
+
}),
|
|
278
|
+
{
|
|
279
|
+
prefixBase: 'proxy:test:api',
|
|
280
|
+
}
|
|
281
|
+
);
|
|
282
|
+
|
|
283
|
+
expect(response).toBeNull();
|
|
284
|
+
expect(mocks.hasAppealRelief).toHaveBeenCalledWith(
|
|
285
|
+
expect.stringMatching(/^session:/),
|
|
286
|
+
'ip:1.2.3.4'
|
|
287
|
+
);
|
|
288
|
+
expect(mocks.limit).toHaveBeenCalledTimes(3);
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
it('keeps unrelated blocked sessions without temporary relief blocked', async () => {
|
|
292
|
+
vi.stubEnv('NODE_ENV', 'production');
|
|
293
|
+
mocks.extractIp.mockReturnValue('1.2.3.4');
|
|
294
|
+
mocks.isBlocked.mockResolvedValue({
|
|
295
|
+
expiresAt: new Date(Date.now() + 30_000),
|
|
296
|
+
reason: 'abuse',
|
|
297
|
+
blockLevel: 'temporary',
|
|
298
|
+
});
|
|
299
|
+
mocks.hasAppealRelief.mockResolvedValue(false);
|
|
300
|
+
|
|
301
|
+
const { guardApiProxyRequest, clearApiProxyGuardLimiterCache } =
|
|
302
|
+
await import('../api-proxy-guard.js');
|
|
303
|
+
clearApiProxyGuardLimiterCache();
|
|
304
|
+
|
|
305
|
+
const response = await guardApiProxyRequest(
|
|
306
|
+
makeRequest('/api/test', 'POST', {
|
|
307
|
+
cookie: 'tuturuuu_app_session=other-session',
|
|
308
|
+
}),
|
|
309
|
+
{
|
|
310
|
+
prefixBase: 'proxy:test:api',
|
|
311
|
+
}
|
|
312
|
+
);
|
|
313
|
+
|
|
314
|
+
expect(response?.status).toBe(429);
|
|
315
|
+
expect(response?.headers.get('X-Proxy-Block-Reason')).toBe(
|
|
316
|
+
'ip-already-blocked'
|
|
317
|
+
);
|
|
318
|
+
expect(mocks.limit).not.toHaveBeenCalled();
|
|
319
|
+
});
|
|
320
|
+
|
|
175
321
|
it('rate limits anonymous generic GET routes', async () => {
|
|
176
322
|
vi.stubEnv('NODE_ENV', 'production');
|
|
177
323
|
vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.test');
|
|
@@ -601,6 +747,101 @@ describe('guardApiProxyRequest', () => {
|
|
|
601
747
|
);
|
|
602
748
|
});
|
|
603
749
|
|
|
750
|
+
it('uses a dedicated users-admin read bucket for lightweight user reads', async () => {
|
|
751
|
+
vi.stubEnv('NODE_ENV', 'production');
|
|
752
|
+
vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.test');
|
|
753
|
+
vi.stubEnv('UPSTASH_REDIS_REST_TOKEN', 'token');
|
|
754
|
+
mocks.redis.mockReturnValue({});
|
|
755
|
+
mocks.extractIp.mockReturnValue('1.2.3.4');
|
|
756
|
+
mocks.isBlocked.mockResolvedValue(null);
|
|
757
|
+
mocks.limit.mockResolvedValue({
|
|
758
|
+
success: false,
|
|
759
|
+
limit: 1200,
|
|
760
|
+
remaining: 0,
|
|
761
|
+
reset: Date.now() + 15_000,
|
|
762
|
+
});
|
|
763
|
+
|
|
764
|
+
const { guardApiProxyRequest, clearApiProxyGuardLimiterCache } =
|
|
765
|
+
await import('../api-proxy-guard.js');
|
|
766
|
+
clearApiProxyGuardLimiterCache();
|
|
767
|
+
|
|
768
|
+
for (const path of [
|
|
769
|
+
'/api/v1/workspaces/ws-1/users/database',
|
|
770
|
+
'/api/v1/workspaces/ws-1/users/groups',
|
|
771
|
+
'/api/v1/workspaces/ws-1/users/11111111-1111-4111-8111-111111111111/attendance',
|
|
772
|
+
'/api/v1/workspaces/ws-1/users/11111111-1111-4111-8111-111111111111/emails',
|
|
773
|
+
'/api/v1/workspaces/ws-1/users/11111111-1111-4111-8111-111111111111/referrals',
|
|
774
|
+
'/api/v1/workspaces/ws-1/users/feedbacks',
|
|
775
|
+
'/api/v1/workspaces/ws-1/user-groups/group-1',
|
|
776
|
+
'/api/v1/workspaces/ws-1/user-groups/group-1/members',
|
|
777
|
+
'/api/v1/workspaces/ws-1/user-groups/group-1/members/user-1/feedbacks',
|
|
778
|
+
'/api/v1/workspaces/ws-1/user-groups/group-1/attendance',
|
|
779
|
+
'/api/v1/workspaces/ws-1/user-groups/group-1/posts/post-1/status',
|
|
780
|
+
'/api/v1/workspaces/ws-1/user-groups/group-1/linked-products',
|
|
781
|
+
'/api/v1/workspaces/ws-1/user-groups/group-1/indicators/categories/category-1',
|
|
782
|
+
]) {
|
|
783
|
+
const response = await guardApiProxyRequest(makeRequest(path, 'GET'), {
|
|
784
|
+
prefixBase: 'proxy:test:api',
|
|
785
|
+
});
|
|
786
|
+
|
|
787
|
+
expect(response?.status).toBe(429);
|
|
788
|
+
expect(response?.headers.get('X-RateLimit-Limit')).toBe('1200');
|
|
789
|
+
expect(response?.headers.get('X-RateLimit-Policy')).toBe(
|
|
790
|
+
'users-admin-read'
|
|
791
|
+
);
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
expect(mocks.ratelimitConfigs).toContainEqual({
|
|
795
|
+
limit: 1200,
|
|
796
|
+
window: '1 m',
|
|
797
|
+
});
|
|
798
|
+
expect(mocks.ratelimitConfigs).toContainEqual({
|
|
799
|
+
limit: 12_000,
|
|
800
|
+
window: '1 h',
|
|
801
|
+
});
|
|
802
|
+
expect(mocks.ratelimitConfigs).toContainEqual({
|
|
803
|
+
limit: 80_000,
|
|
804
|
+
window: '1 d',
|
|
805
|
+
});
|
|
806
|
+
expect(mocks.ratelimitPrefixes).toContain(
|
|
807
|
+
'proxy:test:api:users-admin-read:anonymous:get:minute'
|
|
808
|
+
);
|
|
809
|
+
});
|
|
810
|
+
|
|
811
|
+
it('keeps user admin mutations on mutation buckets', async () => {
|
|
812
|
+
vi.stubEnv('NODE_ENV', 'production');
|
|
813
|
+
vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.test');
|
|
814
|
+
vi.stubEnv('UPSTASH_REDIS_REST_TOKEN', 'token');
|
|
815
|
+
mocks.redis.mockReturnValue({});
|
|
816
|
+
mocks.extractIp.mockReturnValue('1.2.3.4');
|
|
817
|
+
mocks.isBlocked.mockResolvedValue(null);
|
|
818
|
+
mocks.limit.mockResolvedValue({
|
|
819
|
+
success: false,
|
|
820
|
+
limit: 30,
|
|
821
|
+
remaining: 0,
|
|
822
|
+
reset: Date.now() + 15_000,
|
|
823
|
+
});
|
|
824
|
+
|
|
825
|
+
const { guardApiProxyRequest, clearApiProxyGuardLimiterCache } =
|
|
826
|
+
await import('../api-proxy-guard.js');
|
|
827
|
+
clearApiProxyGuardLimiterCache();
|
|
828
|
+
|
|
829
|
+
for (const path of [
|
|
830
|
+
'/api/v1/workspaces/ws-1/users/feedbacks',
|
|
831
|
+
'/api/v1/workspaces/ws-1/user-groups/group-1/members',
|
|
832
|
+
'/api/v1/workspaces/ws-1/user-groups/group-1/attendance',
|
|
833
|
+
'/api/v1/workspaces/ws-1/user-groups/group-1/linked-products',
|
|
834
|
+
]) {
|
|
835
|
+
const response = await guardApiProxyRequest(makeRequest(path, 'POST'), {
|
|
836
|
+
prefixBase: 'proxy:test:api',
|
|
837
|
+
});
|
|
838
|
+
|
|
839
|
+
expect(response?.status).toBe(429);
|
|
840
|
+
expect(response?.headers.get('X-RateLimit-Limit')).toBe('30');
|
|
841
|
+
expect(response?.headers.get('X-RateLimit-Policy')).toBe('default');
|
|
842
|
+
}
|
|
843
|
+
});
|
|
844
|
+
|
|
604
845
|
it('keeps users mutations on the default mutation bucket', async () => {
|
|
605
846
|
vi.stubEnv('NODE_ENV', 'production');
|
|
606
847
|
vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.test');
|
|
@@ -673,6 +914,110 @@ describe('guardApiProxyRequest', () => {
|
|
|
673
914
|
);
|
|
674
915
|
});
|
|
675
916
|
|
|
917
|
+
it('keys verified calendar event deletes by authenticated session', async () => {
|
|
918
|
+
vi.stubEnv('NODE_ENV', 'production');
|
|
919
|
+
vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.test');
|
|
920
|
+
vi.stubEnv('UPSTASH_REDIS_REST_TOKEN', 'token');
|
|
921
|
+
vi.stubEnv('NEXT_PUBLIC_SUPABASE_URL', '');
|
|
922
|
+
mocks.redis.mockReturnValue({});
|
|
923
|
+
mocks.extractIp.mockReturnValue('1.2.3.4');
|
|
924
|
+
mocks.isBlocked.mockResolvedValue(null);
|
|
925
|
+
mocks.getTrustEntries.mockImplementation((keys: string[]) => {
|
|
926
|
+
const map = new Map<string, { m: number; verified: true }>();
|
|
927
|
+
const sessionKey = keys.find((key) => key.startsWith('session:'));
|
|
928
|
+
if (sessionKey) {
|
|
929
|
+
map.set(sessionKey, { m: 1, verified: true });
|
|
930
|
+
}
|
|
931
|
+
return Promise.resolve(map);
|
|
932
|
+
});
|
|
933
|
+
mocks.limit.mockResolvedValueOnce({
|
|
934
|
+
success: false,
|
|
935
|
+
limit: 60,
|
|
936
|
+
remaining: 0,
|
|
937
|
+
reset: Date.now() + 15_000,
|
|
938
|
+
});
|
|
939
|
+
|
|
940
|
+
const { guardApiProxyRequest, clearApiProxyGuardLimiterCache } =
|
|
941
|
+
await import('../api-proxy-guard.js');
|
|
942
|
+
clearApiProxyGuardLimiterCache();
|
|
943
|
+
|
|
944
|
+
const response = await guardApiProxyRequest(
|
|
945
|
+
makeRequest(
|
|
946
|
+
'/api/v1/workspaces/071e0fc7-9aa8-42d8-92e5-cc9b3aeec2f1/calendar/events/68e18a3a-c527-421e-8ac3-f8b846175622',
|
|
947
|
+
'DELETE',
|
|
948
|
+
{
|
|
949
|
+
cookie:
|
|
950
|
+
'sb-resolved-kingfish-21146-auth-token.0=base64-validvalue; theme=dark',
|
|
951
|
+
}
|
|
952
|
+
),
|
|
953
|
+
{ prefixBase: 'proxy:test:api' }
|
|
954
|
+
);
|
|
955
|
+
|
|
956
|
+
expect(response?.status).toBe(429);
|
|
957
|
+
expect(response?.headers.get('X-RateLimit-Caller-Class')).toBe(
|
|
958
|
+
'authenticated'
|
|
959
|
+
);
|
|
960
|
+
expect(response?.headers.get('X-RateLimit-Limit')).toBe('60');
|
|
961
|
+
expect(response?.headers.get('X-RateLimit-Policy')).toBe('default');
|
|
962
|
+
expect(mocks.ratelimitConfigs).toContainEqual({
|
|
963
|
+
limit: 60,
|
|
964
|
+
window: '1 m',
|
|
965
|
+
});
|
|
966
|
+
expect(mocks.ratelimitPrefixes).toContain(
|
|
967
|
+
'proxy:test:api:default:authenticated:mutate:minute'
|
|
968
|
+
);
|
|
969
|
+
expect(mocks.ratelimitPrefixes).not.toContain(
|
|
970
|
+
'proxy:test:api:default:authenticated:t1:mutate:minute'
|
|
971
|
+
);
|
|
972
|
+
const limiterId = mocks.limit.mock.calls[0]?.[0] as string;
|
|
973
|
+
expect(limiterId.startsWith('session:')).toBe(true);
|
|
974
|
+
});
|
|
975
|
+
|
|
976
|
+
it('keeps unverified calendar event deletes on the anonymous IP bucket', async () => {
|
|
977
|
+
vi.stubEnv('NODE_ENV', 'production');
|
|
978
|
+
vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.test');
|
|
979
|
+
vi.stubEnv('UPSTASH_REDIS_REST_TOKEN', 'token');
|
|
980
|
+
vi.stubEnv('NEXT_PUBLIC_SUPABASE_URL', '');
|
|
981
|
+
mocks.redis.mockReturnValue({});
|
|
982
|
+
mocks.extractIp.mockReturnValue('1.2.3.4');
|
|
983
|
+
mocks.isBlocked.mockResolvedValue(null);
|
|
984
|
+
mocks.limit.mockResolvedValueOnce({
|
|
985
|
+
success: false,
|
|
986
|
+
limit: 30,
|
|
987
|
+
remaining: 0,
|
|
988
|
+
reset: Date.now() + 15_000,
|
|
989
|
+
});
|
|
990
|
+
|
|
991
|
+
const { guardApiProxyRequest, clearApiProxyGuardLimiterCache } =
|
|
992
|
+
await import('../api-proxy-guard.js');
|
|
993
|
+
clearApiProxyGuardLimiterCache();
|
|
994
|
+
|
|
995
|
+
const response = await guardApiProxyRequest(
|
|
996
|
+
makeRequest(
|
|
997
|
+
'/api/v1/workspaces/071e0fc7-9aa8-42d8-92e5-cc9b3aeec2f1/calendar/events/68e18a3a-c527-421e-8ac3-f8b846175622',
|
|
998
|
+
'DELETE',
|
|
999
|
+
{
|
|
1000
|
+
cookie:
|
|
1001
|
+
'sb-resolved-kingfish-21146-auth-token.0=forged-value; theme=dark',
|
|
1002
|
+
}
|
|
1003
|
+
),
|
|
1004
|
+
{ prefixBase: 'proxy:test:api' }
|
|
1005
|
+
);
|
|
1006
|
+
|
|
1007
|
+
expect(response?.status).toBe(429);
|
|
1008
|
+
expect(response?.headers.get('X-RateLimit-Caller-Class')).toBe('anonymous');
|
|
1009
|
+
expect(response?.headers.get('X-RateLimit-Limit')).toBe('30');
|
|
1010
|
+
expect(response?.headers.get('X-RateLimit-Policy')).toBe('default');
|
|
1011
|
+
expect(mocks.ratelimitConfigs).toContainEqual({
|
|
1012
|
+
limit: 30,
|
|
1013
|
+
window: '1 m',
|
|
1014
|
+
});
|
|
1015
|
+
expect(mocks.ratelimitPrefixes).toContain(
|
|
1016
|
+
'proxy:test:api:default:anonymous:mutate:minute'
|
|
1017
|
+
);
|
|
1018
|
+
expect(mocks.limit).toHaveBeenCalledWith('ip:1.2.3.4');
|
|
1019
|
+
});
|
|
1020
|
+
|
|
676
1021
|
it('keeps unauthenticated cron reads on a strict proxy bucket', async () => {
|
|
677
1022
|
vi.stubEnv('NODE_ENV', 'production');
|
|
678
1023
|
vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.test');
|
|
@@ -699,141 +1044,333 @@ describe('guardApiProxyRequest', () => {
|
|
|
699
1044
|
);
|
|
700
1045
|
|
|
701
1046
|
expect(response?.status).toBe(429);
|
|
702
|
-
expect(response?.headers.get('X-RateLimit-Limit')).toBe('10');
|
|
1047
|
+
expect(response?.headers.get('X-RateLimit-Limit')).toBe('10');
|
|
1048
|
+
expect(response?.headers.get('X-RateLimit-Caller-Class')).toBe('anonymous');
|
|
1049
|
+
expect(response?.headers.get('X-RateLimit-Policy')).toBe('cron');
|
|
1050
|
+
});
|
|
1051
|
+
|
|
1052
|
+
it('does not grant elevated proxy budgets from authenticated-looking headers alone', async () => {
|
|
1053
|
+
vi.stubEnv('NODE_ENV', 'production');
|
|
1054
|
+
vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.test');
|
|
1055
|
+
vi.stubEnv('UPSTASH_REDIS_REST_TOKEN', 'token');
|
|
1056
|
+
mocks.redis.mockReturnValue({});
|
|
1057
|
+
mocks.extractIp.mockReturnValue('1.2.3.4');
|
|
1058
|
+
mocks.isBlocked.mockResolvedValue(null);
|
|
1059
|
+
mocks.limit.mockResolvedValueOnce({
|
|
1060
|
+
success: false,
|
|
1061
|
+
limit: 30,
|
|
1062
|
+
remaining: 0,
|
|
1063
|
+
reset: Date.now() + 15_000,
|
|
1064
|
+
});
|
|
1065
|
+
mocks.validateEmoji.mockResolvedValue(null);
|
|
1066
|
+
|
|
1067
|
+
const { guardApiProxyRequest, clearApiProxyGuardLimiterCache } =
|
|
1068
|
+
await import('../api-proxy-guard.js');
|
|
1069
|
+
clearApiProxyGuardLimiterCache();
|
|
1070
|
+
|
|
1071
|
+
const response = await guardApiProxyRequest(
|
|
1072
|
+
makeRequest('/api/test', 'POST', {
|
|
1073
|
+
authorization: 'Bearer ttr_fake',
|
|
1074
|
+
}),
|
|
1075
|
+
{
|
|
1076
|
+
prefixBase: 'proxy:test:api',
|
|
1077
|
+
}
|
|
1078
|
+
);
|
|
1079
|
+
|
|
1080
|
+
expect(response?.status).toBe(429);
|
|
1081
|
+
expect(response?.headers.get('X-RateLimit-Caller-Class')).toBe('anonymous');
|
|
1082
|
+
expect(response?.headers.get('X-RateLimit-Policy')).toBe('default');
|
|
1083
|
+
expect(mocks.isBlocked).toHaveBeenCalledWith('1.2.3.4');
|
|
1084
|
+
expect(mocks.limit).toHaveBeenCalledTimes(1);
|
|
1085
|
+
expect(mocks.validateEmoji).not.toHaveBeenCalled();
|
|
1086
|
+
});
|
|
1087
|
+
|
|
1088
|
+
it('uses local rate limits when the Redis rate limiter is unreachable', async () => {
|
|
1089
|
+
vi.stubEnv('NODE_ENV', 'production');
|
|
1090
|
+
vi.stubEnv(
|
|
1091
|
+
'UPSTASH_REDIS_REST_URL',
|
|
1092
|
+
'https://resolved-kingfish-21146.upstash.io'
|
|
1093
|
+
);
|
|
1094
|
+
vi.stubEnv('UPSTASH_REDIS_REST_TOKEN', 'token');
|
|
1095
|
+
mocks.redis.mockReturnValue({});
|
|
1096
|
+
mocks.extractIp.mockReturnValue('1.2.3.4');
|
|
1097
|
+
mocks.isBlocked.mockResolvedValue(null);
|
|
1098
|
+
mocks.limit.mockRejectedValueOnce(new TypeError('fetch failed'));
|
|
1099
|
+
mocks.validateEmoji.mockResolvedValue(null);
|
|
1100
|
+
|
|
1101
|
+
const { guardApiProxyRequest, clearApiProxyGuardLimiterCache } =
|
|
1102
|
+
await import('../api-proxy-guard.js');
|
|
1103
|
+
clearApiProxyGuardLimiterCache();
|
|
1104
|
+
|
|
1105
|
+
const options = {
|
|
1106
|
+
prefixBase: 'proxy:test:api',
|
|
1107
|
+
routePolicies: singleReadRoutePolicies(),
|
|
1108
|
+
};
|
|
1109
|
+
|
|
1110
|
+
const response = await guardApiProxyRequest(
|
|
1111
|
+
makeRequest('/api/test', 'GET'),
|
|
1112
|
+
options
|
|
1113
|
+
);
|
|
1114
|
+
|
|
1115
|
+
expect(response).toBeNull();
|
|
1116
|
+
expect(mocks.limit).toHaveBeenCalledTimes(1);
|
|
1117
|
+
|
|
1118
|
+
const blockedResponse = await guardApiProxyRequest(
|
|
1119
|
+
makeRequest('/api/test', 'GET'),
|
|
1120
|
+
options
|
|
1121
|
+
);
|
|
1122
|
+
|
|
1123
|
+
expect(blockedResponse?.status).toBe(429);
|
|
1124
|
+
expect(blockedResponse?.headers.get('X-Proxy-Block-Reason')).toBe(
|
|
1125
|
+
'route-rate-limit'
|
|
1126
|
+
);
|
|
1127
|
+
expect(blockedResponse?.headers.get('X-RateLimit-Policy')).toBe(
|
|
1128
|
+
'redis-fallback-test'
|
|
1129
|
+
);
|
|
1130
|
+
expect(mocks.limit).toHaveBeenCalledTimes(1);
|
|
1131
|
+
});
|
|
1132
|
+
|
|
1133
|
+
it('uses local rate limits when Redis client initialization fails', async () => {
|
|
1134
|
+
vi.stubEnv('NODE_ENV', 'production');
|
|
1135
|
+
vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.test');
|
|
1136
|
+
vi.stubEnv('UPSTASH_REDIS_REST_TOKEN', 'token');
|
|
1137
|
+
mocks.redis.mockImplementation(() => {
|
|
1138
|
+
throw new TypeError('Redis unavailable');
|
|
1139
|
+
});
|
|
1140
|
+
mocks.extractIp.mockReturnValue('1.2.3.4');
|
|
1141
|
+
mocks.isBlocked.mockResolvedValue(null);
|
|
1142
|
+
mocks.validateEmoji.mockResolvedValue(null);
|
|
1143
|
+
|
|
1144
|
+
const { guardApiProxyRequest, clearApiProxyGuardLimiterCache } =
|
|
1145
|
+
await import('../api-proxy-guard.js');
|
|
1146
|
+
clearApiProxyGuardLimiterCache();
|
|
1147
|
+
|
|
1148
|
+
const options = {
|
|
1149
|
+
prefixBase: 'proxy:test:api',
|
|
1150
|
+
routePolicies: singleReadRoutePolicies(),
|
|
1151
|
+
};
|
|
1152
|
+
|
|
1153
|
+
const response = await guardApiProxyRequest(
|
|
1154
|
+
makeRequest('/api/test', 'GET'),
|
|
1155
|
+
options
|
|
1156
|
+
);
|
|
1157
|
+
|
|
1158
|
+
expect(response).toBeNull();
|
|
1159
|
+
|
|
1160
|
+
const blockedResponse = await guardApiProxyRequest(
|
|
1161
|
+
makeRequest('/api/test', 'GET'),
|
|
1162
|
+
options
|
|
1163
|
+
);
|
|
1164
|
+
|
|
1165
|
+
expect(blockedResponse?.status).toBe(429);
|
|
1166
|
+
expect(blockedResponse?.headers.get('X-Proxy-Block-Reason')).toBe(
|
|
1167
|
+
'route-rate-limit'
|
|
1168
|
+
);
|
|
1169
|
+
expect(mocks.limit).not.toHaveBeenCalled();
|
|
1170
|
+
});
|
|
1171
|
+
|
|
1172
|
+
it('rate limits anonymous users/me reads', async () => {
|
|
1173
|
+
vi.stubEnv('NODE_ENV', 'production');
|
|
1174
|
+
vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.test');
|
|
1175
|
+
vi.stubEnv('UPSTASH_REDIS_REST_TOKEN', 'token');
|
|
1176
|
+
mocks.redis.mockReturnValue({});
|
|
1177
|
+
mocks.extractIp.mockReturnValue('1.2.3.4');
|
|
1178
|
+
mocks.isBlocked.mockResolvedValue(null);
|
|
1179
|
+
mocks.limit.mockResolvedValueOnce({
|
|
1180
|
+
success: false,
|
|
1181
|
+
limit: 20,
|
|
1182
|
+
remaining: 0,
|
|
1183
|
+
reset: Date.now() + 15_000,
|
|
1184
|
+
});
|
|
1185
|
+
|
|
1186
|
+
const { guardApiProxyRequest, clearApiProxyGuardLimiterCache } =
|
|
1187
|
+
await import('../api-proxy-guard.js');
|
|
1188
|
+
clearApiProxyGuardLimiterCache();
|
|
1189
|
+
|
|
1190
|
+
const response = await guardApiProxyRequest(
|
|
1191
|
+
makeRequest('/api/v1/users/me/configs/demo', 'GET'),
|
|
1192
|
+
{ prefixBase: 'proxy:test:api' }
|
|
1193
|
+
);
|
|
1194
|
+
|
|
1195
|
+
expect(response?.status).toBe(429);
|
|
1196
|
+
expect(response?.headers.get('X-RateLimit-Limit')).toBe('20');
|
|
703
1197
|
expect(response?.headers.get('X-RateLimit-Caller-Class')).toBe('anonymous');
|
|
704
|
-
expect(response?.headers.get('X-RateLimit-Policy')).toBe('cron');
|
|
705
1198
|
});
|
|
706
1199
|
|
|
707
|
-
it('
|
|
1200
|
+
it('keys verified users/me reads by session without applying a trust uplift', async () => {
|
|
708
1201
|
vi.stubEnv('NODE_ENV', 'production');
|
|
709
1202
|
vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.test');
|
|
710
1203
|
vi.stubEnv('UPSTASH_REDIS_REST_TOKEN', 'token');
|
|
1204
|
+
vi.stubEnv('NEXT_PUBLIC_SUPABASE_URL', '');
|
|
711
1205
|
mocks.redis.mockReturnValue({});
|
|
712
1206
|
mocks.extractIp.mockReturnValue('1.2.3.4');
|
|
713
1207
|
mocks.isBlocked.mockResolvedValue(null);
|
|
1208
|
+
mocks.getTrustEntries.mockImplementation((keys: string[]) => {
|
|
1209
|
+
const map = new Map<string, { m: number; verified: true }>();
|
|
1210
|
+
const sessionKey = keys.find((key) => key.startsWith('session:'));
|
|
1211
|
+
if (sessionKey) {
|
|
1212
|
+
map.set(sessionKey, { m: 1, verified: true });
|
|
1213
|
+
}
|
|
1214
|
+
return Promise.resolve(map);
|
|
1215
|
+
});
|
|
714
1216
|
mocks.limit.mockResolvedValueOnce({
|
|
715
1217
|
success: false,
|
|
716
|
-
limit:
|
|
1218
|
+
limit: 600,
|
|
717
1219
|
remaining: 0,
|
|
718
1220
|
reset: Date.now() + 15_000,
|
|
719
1221
|
});
|
|
720
|
-
mocks.validateEmoji.mockResolvedValue(null);
|
|
721
1222
|
|
|
722
1223
|
const { guardApiProxyRequest, clearApiProxyGuardLimiterCache } =
|
|
723
1224
|
await import('../api-proxy-guard.js');
|
|
724
1225
|
clearApiProxyGuardLimiterCache();
|
|
725
1226
|
|
|
726
1227
|
const response = await guardApiProxyRequest(
|
|
727
|
-
makeRequest('/api/
|
|
728
|
-
|
|
1228
|
+
makeRequest('/api/v1/users/me/profile', 'GET', {
|
|
1229
|
+
cookie:
|
|
1230
|
+
'sb-resolved-kingfish-21146-auth-token.0=base64-validvalue; theme=dark',
|
|
729
1231
|
}),
|
|
730
|
-
{
|
|
731
|
-
prefixBase: 'proxy:test:api',
|
|
732
|
-
}
|
|
1232
|
+
{ prefixBase: 'proxy:test:api' }
|
|
733
1233
|
);
|
|
734
1234
|
|
|
735
1235
|
expect(response?.status).toBe(429);
|
|
736
|
-
expect(response?.headers.get('X-RateLimit-Caller-Class')).toBe(
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
expect(
|
|
740
|
-
expect(
|
|
1236
|
+
expect(response?.headers.get('X-RateLimit-Caller-Class')).toBe(
|
|
1237
|
+
'authenticated'
|
|
1238
|
+
);
|
|
1239
|
+
expect(response?.headers.get('X-RateLimit-Limit')).toBe('600');
|
|
1240
|
+
expect(response?.headers.get('X-RateLimit-Policy')).toBe('users-me');
|
|
1241
|
+
expect(mocks.ratelimitConfigs).toContainEqual({
|
|
1242
|
+
limit: 600,
|
|
1243
|
+
window: '1 m',
|
|
1244
|
+
});
|
|
1245
|
+
expect(mocks.ratelimitPrefixes).toContain(
|
|
1246
|
+
'proxy:test:api:users-me:authenticated:get:minute'
|
|
1247
|
+
);
|
|
1248
|
+
expect(mocks.ratelimitPrefixes).not.toContain(
|
|
1249
|
+
'proxy:test:api:users-me:authenticated:t1:get:minute'
|
|
1250
|
+
);
|
|
1251
|
+
const limiterId = mocks.limit.mock.calls[0]?.[0] as string;
|
|
1252
|
+
expect(limiterId.startsWith('session:')).toBe(true);
|
|
741
1253
|
});
|
|
742
1254
|
|
|
743
|
-
it('uses
|
|
1255
|
+
it('uses the authenticated workspace read policy for verified dashboard reads', async () => {
|
|
744
1256
|
vi.stubEnv('NODE_ENV', 'production');
|
|
745
|
-
vi.stubEnv(
|
|
746
|
-
'UPSTASH_REDIS_REST_URL',
|
|
747
|
-
'https://resolved-kingfish-21146.upstash.io'
|
|
748
|
-
);
|
|
1257
|
+
vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.test');
|
|
749
1258
|
vi.stubEnv('UPSTASH_REDIS_REST_TOKEN', 'token');
|
|
1259
|
+
vi.stubEnv('NEXT_PUBLIC_SUPABASE_URL', '');
|
|
750
1260
|
mocks.redis.mockReturnValue({});
|
|
751
1261
|
mocks.extractIp.mockReturnValue('1.2.3.4');
|
|
752
1262
|
mocks.isBlocked.mockResolvedValue(null);
|
|
753
|
-
mocks.
|
|
754
|
-
|
|
1263
|
+
mocks.getTrustEntries.mockImplementation((keys: string[]) => {
|
|
1264
|
+
const map = new Map<string, { m: number; verified: true }>();
|
|
1265
|
+
const sessionKey = keys.find((key) => key.startsWith('session:'));
|
|
1266
|
+
if (sessionKey) {
|
|
1267
|
+
map.set(sessionKey, { m: 1, verified: true });
|
|
1268
|
+
}
|
|
1269
|
+
return Promise.resolve(map);
|
|
1270
|
+
});
|
|
1271
|
+
mocks.limit.mockResolvedValueOnce({
|
|
1272
|
+
success: false,
|
|
1273
|
+
limit: 600,
|
|
1274
|
+
remaining: 0,
|
|
1275
|
+
reset: Date.now() + 15_000,
|
|
1276
|
+
});
|
|
755
1277
|
|
|
756
1278
|
const { guardApiProxyRequest, clearApiProxyGuardLimiterCache } =
|
|
757
1279
|
await import('../api-proxy-guard.js');
|
|
758
1280
|
clearApiProxyGuardLimiterCache();
|
|
759
1281
|
|
|
760
|
-
const options = {
|
|
761
|
-
prefixBase: 'proxy:test:api',
|
|
762
|
-
routePolicies: singleReadRoutePolicies(),
|
|
763
|
-
};
|
|
764
|
-
|
|
765
1282
|
const response = await guardApiProxyRequest(
|
|
766
|
-
makeRequest('/api/
|
|
767
|
-
|
|
1283
|
+
makeRequest('/api/v1/workspaces/ws-1/dashboard/bootstrap', 'GET', {
|
|
1284
|
+
cookie:
|
|
1285
|
+
'sb-resolved-kingfish-21146-auth-token.0=base64-validvalue; theme=dark',
|
|
1286
|
+
}),
|
|
1287
|
+
{ prefixBase: 'proxy:test:api' }
|
|
768
1288
|
);
|
|
769
1289
|
|
|
770
|
-
expect(response).
|
|
771
|
-
expect(
|
|
772
|
-
|
|
773
|
-
const blockedResponse = await guardApiProxyRequest(
|
|
774
|
-
makeRequest('/api/test', 'GET'),
|
|
775
|
-
options
|
|
1290
|
+
expect(response?.status).toBe(429);
|
|
1291
|
+
expect(response?.headers.get('X-RateLimit-Caller-Class')).toBe(
|
|
1292
|
+
'authenticated'
|
|
776
1293
|
);
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
expect(blockedResponse?.headers.get('X-Proxy-Block-Reason')).toBe(
|
|
780
|
-
'route-rate-limit'
|
|
1294
|
+
expect(response?.headers.get('X-RateLimit-Policy')).toBe(
|
|
1295
|
+
'workspace-dashboard-read'
|
|
781
1296
|
);
|
|
782
|
-
expect(
|
|
783
|
-
'
|
|
1297
|
+
expect(mocks.ratelimitPrefixes).toContain(
|
|
1298
|
+
'proxy:test:api:workspace-dashboard-read:authenticated:get:minute'
|
|
784
1299
|
);
|
|
785
|
-
|
|
1300
|
+
const limiterId = mocks.limit.mock.calls[0]?.[0] as string;
|
|
1301
|
+
expect(limiterId.startsWith('session:')).toBe(true);
|
|
786
1302
|
});
|
|
787
1303
|
|
|
788
|
-
it('uses
|
|
1304
|
+
it('uses authenticated default read limits for verified sessions on generic reads', async () => {
|
|
789
1305
|
vi.stubEnv('NODE_ENV', 'production');
|
|
790
1306
|
vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.test');
|
|
791
1307
|
vi.stubEnv('UPSTASH_REDIS_REST_TOKEN', 'token');
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
});
|
|
1308
|
+
vi.stubEnv('NEXT_PUBLIC_SUPABASE_URL', '');
|
|
1309
|
+
mocks.redis.mockReturnValue({});
|
|
795
1310
|
mocks.extractIp.mockReturnValue('1.2.3.4');
|
|
796
1311
|
mocks.isBlocked.mockResolvedValue(null);
|
|
797
|
-
mocks.
|
|
1312
|
+
mocks.getTrustEntries.mockImplementation((keys: string[]) => {
|
|
1313
|
+
const map = new Map<string, { m: number; verified: true }>();
|
|
1314
|
+
const sessionKey = keys.find((key) => key.startsWith('session:'));
|
|
1315
|
+
if (sessionKey) {
|
|
1316
|
+
map.set(sessionKey, { m: 1, verified: true });
|
|
1317
|
+
}
|
|
1318
|
+
return Promise.resolve(map);
|
|
1319
|
+
});
|
|
1320
|
+
mocks.limit.mockResolvedValueOnce({
|
|
1321
|
+
success: false,
|
|
1322
|
+
limit: 600,
|
|
1323
|
+
remaining: 0,
|
|
1324
|
+
reset: Date.now() + 15_000,
|
|
1325
|
+
});
|
|
798
1326
|
|
|
799
1327
|
const { guardApiProxyRequest, clearApiProxyGuardLimiterCache } =
|
|
800
1328
|
await import('../api-proxy-guard.js');
|
|
801
1329
|
clearApiProxyGuardLimiterCache();
|
|
802
1330
|
|
|
803
|
-
const options = {
|
|
804
|
-
prefixBase: 'proxy:test:api',
|
|
805
|
-
routePolicies: singleReadRoutePolicies(),
|
|
806
|
-
};
|
|
807
|
-
|
|
808
1331
|
const response = await guardApiProxyRequest(
|
|
809
|
-
makeRequest('/api/
|
|
810
|
-
|
|
1332
|
+
makeRequest('/api/v1/unknown/read-only-dashboard-fragment', 'GET', {
|
|
1333
|
+
cookie:
|
|
1334
|
+
'sb-resolved-kingfish-21146-auth-token.0=base64-validvalue; theme=dark',
|
|
1335
|
+
}),
|
|
1336
|
+
{ prefixBase: 'proxy:test:api' }
|
|
811
1337
|
);
|
|
812
1338
|
|
|
813
|
-
expect(response).
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
makeRequest('/api/test', 'GET'),
|
|
817
|
-
options
|
|
1339
|
+
expect(response?.status).toBe(429);
|
|
1340
|
+
expect(response?.headers.get('X-RateLimit-Caller-Class')).toBe(
|
|
1341
|
+
'authenticated'
|
|
818
1342
|
);
|
|
819
|
-
|
|
820
|
-
expect(
|
|
821
|
-
expect(
|
|
822
|
-
'
|
|
1343
|
+
expect(response?.headers.get('X-RateLimit-Limit')).toBe('600');
|
|
1344
|
+
expect(response?.headers.get('X-RateLimit-Policy')).toBe('default');
|
|
1345
|
+
expect(mocks.ratelimitPrefixes).toContain(
|
|
1346
|
+
'proxy:test:api:default:authenticated:get:minute'
|
|
823
1347
|
);
|
|
824
|
-
expect(mocks.
|
|
1348
|
+
expect(mocks.ratelimitPrefixes).not.toContain(
|
|
1349
|
+
'proxy:test:api:default:authenticated:t1:get:minute'
|
|
1350
|
+
);
|
|
1351
|
+
const limiterId = mocks.limit.mock.calls[0]?.[0] as string;
|
|
1352
|
+
expect(limiterId.startsWith('session:')).toBe(true);
|
|
825
1353
|
});
|
|
826
1354
|
|
|
827
|
-
it('
|
|
1355
|
+
it('covers legacy workspace reads with the verified workspace dashboard policy', async () => {
|
|
828
1356
|
vi.stubEnv('NODE_ENV', 'production');
|
|
829
1357
|
vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.test');
|
|
830
1358
|
vi.stubEnv('UPSTASH_REDIS_REST_TOKEN', 'token');
|
|
1359
|
+
vi.stubEnv('NEXT_PUBLIC_SUPABASE_URL', '');
|
|
831
1360
|
mocks.redis.mockReturnValue({});
|
|
832
1361
|
mocks.extractIp.mockReturnValue('1.2.3.4');
|
|
833
1362
|
mocks.isBlocked.mockResolvedValue(null);
|
|
1363
|
+
mocks.getTrustEntries.mockImplementation((keys: string[]) => {
|
|
1364
|
+
const map = new Map<string, { m: number; verified: true }>();
|
|
1365
|
+
const sessionKey = keys.find((key) => key.startsWith('session:'));
|
|
1366
|
+
if (sessionKey) {
|
|
1367
|
+
map.set(sessionKey, { m: 1, verified: true });
|
|
1368
|
+
}
|
|
1369
|
+
return Promise.resolve(map);
|
|
1370
|
+
});
|
|
834
1371
|
mocks.limit.mockResolvedValueOnce({
|
|
835
1372
|
success: false,
|
|
836
|
-
limit:
|
|
1373
|
+
limit: 600,
|
|
837
1374
|
remaining: 0,
|
|
838
1375
|
reset: Date.now() + 15_000,
|
|
839
1376
|
});
|
|
@@ -843,13 +1380,25 @@ describe('guardApiProxyRequest', () => {
|
|
|
843
1380
|
clearApiProxyGuardLimiterCache();
|
|
844
1381
|
|
|
845
1382
|
const response = await guardApiProxyRequest(
|
|
846
|
-
makeRequest('/api/
|
|
1383
|
+
makeRequest('/api/workspaces/ws-1/settings', 'GET', {
|
|
1384
|
+
cookie:
|
|
1385
|
+
'sb-resolved-kingfish-21146-auth-token.0=base64-validvalue; theme=dark',
|
|
1386
|
+
}),
|
|
847
1387
|
{ prefixBase: 'proxy:test:api' }
|
|
848
1388
|
);
|
|
849
1389
|
|
|
850
1390
|
expect(response?.status).toBe(429);
|
|
851
|
-
expect(response?.headers.get('X-RateLimit-
|
|
852
|
-
|
|
1391
|
+
expect(response?.headers.get('X-RateLimit-Caller-Class')).toBe(
|
|
1392
|
+
'authenticated'
|
|
1393
|
+
);
|
|
1394
|
+
expect(response?.headers.get('X-RateLimit-Policy')).toBe(
|
|
1395
|
+
'workspace-dashboard-read'
|
|
1396
|
+
);
|
|
1397
|
+
expect(mocks.ratelimitPrefixes).toContain(
|
|
1398
|
+
'proxy:test:api:workspace-dashboard-read:authenticated:get:minute'
|
|
1399
|
+
);
|
|
1400
|
+
const limiterId = mocks.limit.mock.calls[0]?.[0] as string;
|
|
1401
|
+
expect(limiterId.startsWith('session:')).toBe(true);
|
|
853
1402
|
});
|
|
854
1403
|
|
|
855
1404
|
it('rate limits API client-looking bearer reads until route auth validates them', async () => {
|
|
@@ -1603,15 +2152,73 @@ describe('guardApiProxyRequest', () => {
|
|
|
1603
2152
|
limit: 1800,
|
|
1604
2153
|
window: '1 m',
|
|
1605
2154
|
});
|
|
1606
|
-
// Trusted traffic gets
|
|
2155
|
+
// Trusted traffic gets an authenticated, tier-suffixed bucket.
|
|
1607
2156
|
expect(mocks.ratelimitPrefixes).toContain(
|
|
1608
|
-
'proxy:test:api:task-board-read:
|
|
2157
|
+
'proxy:test:api:task-board-read:authenticated:t3:get:minute'
|
|
1609
2158
|
);
|
|
1610
2159
|
// The limiter is keyed by the per-session subject key, not the shared IP.
|
|
1611
2160
|
const limiterId = mocks.limit.mock.calls[0]?.[0] as string;
|
|
1612
2161
|
expect(limiterId.startsWith('session:')).toBe(true);
|
|
1613
2162
|
});
|
|
1614
2163
|
|
|
2164
|
+
it('keeps trusted-session mutation limits flat while using the session bucket', async () => {
|
|
2165
|
+
vi.stubEnv('NODE_ENV', 'production');
|
|
2166
|
+
vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.test');
|
|
2167
|
+
vi.stubEnv('UPSTASH_REDIS_REST_TOKEN', 'token');
|
|
2168
|
+
vi.stubEnv('NEXT_PUBLIC_SUPABASE_URL', '');
|
|
2169
|
+
mocks.redis.mockReturnValue({});
|
|
2170
|
+
mocks.extractIp.mockReturnValue('1.2.3.4');
|
|
2171
|
+
mocks.isBlocked.mockResolvedValue(null);
|
|
2172
|
+
mocks.getTrustEntries.mockImplementation((keys: string[]) => {
|
|
2173
|
+
const map = new Map<string, { m: number }>();
|
|
2174
|
+
const sessionKey = keys.find((key) => key.startsWith('session:'));
|
|
2175
|
+
if (sessionKey) {
|
|
2176
|
+
map.set(sessionKey, { m: 3 });
|
|
2177
|
+
}
|
|
2178
|
+
return Promise.resolve(map);
|
|
2179
|
+
});
|
|
2180
|
+
mocks.limit.mockResolvedValueOnce({
|
|
2181
|
+
success: false,
|
|
2182
|
+
limit: 60,
|
|
2183
|
+
remaining: 0,
|
|
2184
|
+
reset: Date.now() + 15_000,
|
|
2185
|
+
});
|
|
2186
|
+
|
|
2187
|
+
const { guardApiProxyRequest, clearApiProxyGuardLimiterCache } =
|
|
2188
|
+
await import('../api-proxy-guard.js');
|
|
2189
|
+
clearApiProxyGuardLimiterCache();
|
|
2190
|
+
|
|
2191
|
+
const response = await guardApiProxyRequest(
|
|
2192
|
+
makeRequest(
|
|
2193
|
+
'/api/v1/workspaces/071e0fc7-9aa8-42d8-92e5-cc9b3aeec2f1/calendar/events/68e18a3a-c527-421e-8ac3-f8b846175622',
|
|
2194
|
+
'DELETE',
|
|
2195
|
+
{
|
|
2196
|
+
cookie:
|
|
2197
|
+
'sb-resolved-kingfish-21146-auth-token.0=base64-validvalue; theme=dark',
|
|
2198
|
+
}
|
|
2199
|
+
),
|
|
2200
|
+
{ prefixBase: 'proxy:test:api' }
|
|
2201
|
+
);
|
|
2202
|
+
|
|
2203
|
+
expect(response?.status).toBe(429);
|
|
2204
|
+
expect(response?.headers.get('X-RateLimit-Caller-Class')).toBe(
|
|
2205
|
+
'authenticated'
|
|
2206
|
+
);
|
|
2207
|
+
expect(response?.headers.get('X-RateLimit-Limit')).toBe('60');
|
|
2208
|
+
expect(mocks.ratelimitConfigs).toContainEqual({
|
|
2209
|
+
limit: 60,
|
|
2210
|
+
window: '1 m',
|
|
2211
|
+
});
|
|
2212
|
+
expect(mocks.ratelimitPrefixes).toContain(
|
|
2213
|
+
'proxy:test:api:default:authenticated:mutate:minute'
|
|
2214
|
+
);
|
|
2215
|
+
expect(mocks.ratelimitPrefixes).not.toContain(
|
|
2216
|
+
'proxy:test:api:default:authenticated:t3:mutate:minute'
|
|
2217
|
+
);
|
|
2218
|
+
const limiterId = mocks.limit.mock.calls[0]?.[0] as string;
|
|
2219
|
+
expect(limiterId.startsWith('session:')).toBe(true);
|
|
2220
|
+
});
|
|
2221
|
+
|
|
1615
2222
|
it('uplifts a trusted location per-IP without per-session keying', async () => {
|
|
1616
2223
|
vi.stubEnv('NODE_ENV', 'production');
|
|
1617
2224
|
vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.test');
|
|
@@ -1656,6 +2263,56 @@ describe('guardApiProxyRequest', () => {
|
|
|
1656
2263
|
expect(mocks.limit).toHaveBeenCalledWith('ip:1.2.3.4');
|
|
1657
2264
|
});
|
|
1658
2265
|
|
|
2266
|
+
it('does not apply trusted-location read uplift to mutations', async () => {
|
|
2267
|
+
vi.stubEnv('NODE_ENV', 'production');
|
|
2268
|
+
vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.test');
|
|
2269
|
+
vi.stubEnv('UPSTASH_REDIS_REST_TOKEN', 'token');
|
|
2270
|
+
mocks.redis.mockReturnValue({});
|
|
2271
|
+
mocks.extractIp.mockReturnValue('1.2.3.4');
|
|
2272
|
+
mocks.isBlocked.mockResolvedValue(null);
|
|
2273
|
+
const receivedKeys: string[] = [];
|
|
2274
|
+
mocks.getTrustEntries.mockImplementation((keys: string[]) => {
|
|
2275
|
+
receivedKeys.push(...keys);
|
|
2276
|
+
const map = new Map<string, { m: number }>();
|
|
2277
|
+
const cidrKey = keys.find((key) => key.startsWith('cidr:'));
|
|
2278
|
+
if (cidrKey) {
|
|
2279
|
+
map.set(cidrKey, { m: 2 });
|
|
2280
|
+
}
|
|
2281
|
+
return Promise.resolve(map);
|
|
2282
|
+
});
|
|
2283
|
+
mocks.limit.mockResolvedValueOnce({
|
|
2284
|
+
success: false,
|
|
2285
|
+
limit: 30,
|
|
2286
|
+
remaining: 0,
|
|
2287
|
+
reset: Date.now() + 15_000,
|
|
2288
|
+
});
|
|
2289
|
+
|
|
2290
|
+
const { guardApiProxyRequest, clearApiProxyGuardLimiterCache } =
|
|
2291
|
+
await import('../api-proxy-guard.js');
|
|
2292
|
+
clearApiProxyGuardLimiterCache();
|
|
2293
|
+
|
|
2294
|
+
const response = await guardApiProxyRequest(
|
|
2295
|
+
makeRequest('/api/v1/workspaces/ws-1/tasks', 'POST'),
|
|
2296
|
+
{ prefixBase: 'proxy:test:api' }
|
|
2297
|
+
);
|
|
2298
|
+
|
|
2299
|
+
expect(response?.status).toBe(429);
|
|
2300
|
+
expect(response?.headers.get('X-RateLimit-Caller-Class')).toBe('anonymous');
|
|
2301
|
+
expect(response?.headers.get('X-RateLimit-Limit')).toBe('30');
|
|
2302
|
+
expect(mocks.ratelimitConfigs).toContainEqual({
|
|
2303
|
+
limit: 30,
|
|
2304
|
+
window: '1 m',
|
|
2305
|
+
});
|
|
2306
|
+
expect(mocks.ratelimitPrefixes).toContain(
|
|
2307
|
+
'proxy:test:api:default:anonymous:mutate:minute'
|
|
2308
|
+
);
|
|
2309
|
+
expect(mocks.ratelimitPrefixes).not.toContain(
|
|
2310
|
+
'proxy:test:api:default:anonymous:t2:mutate:minute'
|
|
2311
|
+
);
|
|
2312
|
+
expect(receivedKeys.some((key) => key.startsWith('cidr:'))).toBe(false);
|
|
2313
|
+
expect(mocks.limit).toHaveBeenCalledWith('ip:1.2.3.4');
|
|
2314
|
+
});
|
|
2315
|
+
|
|
1659
2316
|
it('keeps a trusted location on per-IP keying even with an untrusted session cookie', async () => {
|
|
1660
2317
|
vi.stubEnv('NODE_ENV', 'production');
|
|
1661
2318
|
vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.test');
|