@creator.co/wapi 1.8.4 → 1.8.5
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/README.md +2 -0
- package/dist/index.d.ts +9 -4
- package/dist/index.js +7 -2
- package/dist/index.js.map +1 -1
- package/dist/package-lock.json +31 -2
- package/dist/package.json +4 -2
- package/dist/src/Server/Router.d.ts +51 -0
- package/dist/src/Server/Router.js.map +1 -1
- package/dist/src/Server/lib/container/Proxy.d.ts +14 -0
- package/dist/src/Server/lib/container/Proxy.js +70 -0
- package/dist/src/Server/lib/container/Proxy.js.map +1 -1
- package/index.ts +17 -3
- package/package.json +4 -2
- package/src/Server/Router.ts +54 -0
- package/src/Server/lib/container/Proxy.ts +83 -1
- package/tests/Server/lib/container/RateLimit.test.ts +772 -0
|
@@ -0,0 +1,772 @@
|
|
|
1
|
+
import { jest } from '@jest/globals'
|
|
2
|
+
import { APIGatewayProxyEvent, Context } from 'aws-lambda'
|
|
3
|
+
import { expect as c_expect } from 'chai'
|
|
4
|
+
import request from 'supertest'
|
|
5
|
+
|
|
6
|
+
import Globals from '../../../../src/Globals.js'
|
|
7
|
+
import Proxy from '../../../../src/Server/lib/container/Proxy.js'
|
|
8
|
+
import { GlobalRateLimitConfig } from '../../../../src/Server/Router.js'
|
|
9
|
+
import { defaultUrl } from '../../../Test.utils.js'
|
|
10
|
+
|
|
11
|
+
describe('Rate Limiting', () => {
|
|
12
|
+
// @ts-ignore
|
|
13
|
+
let mockExit = jest.spyOn(process, 'exit').mockImplementation(() => {})
|
|
14
|
+
let proxy: Proxy | null = null
|
|
15
|
+
|
|
16
|
+
beforeAll(() => {
|
|
17
|
+
// @ts-ignore
|
|
18
|
+
mockExit = jest.spyOn(process, 'exit').mockImplementation(() => {})
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
afterAll(() => {
|
|
22
|
+
mockExit.mockRestore()
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
beforeEach(() => {
|
|
26
|
+
mockExit.mockReset()
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
afterEach(async () => {
|
|
30
|
+
if (proxy) {
|
|
31
|
+
await proxy.unload()
|
|
32
|
+
proxy = null
|
|
33
|
+
}
|
|
34
|
+
// Give ports time to release
|
|
35
|
+
await new Promise(resolve => setTimeout(resolve, 100))
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
test('Rate limit is applied with default settings', async () => {
|
|
39
|
+
const port = 57001
|
|
40
|
+
const url = defaultUrl.replace(String(Globals.Listener_HTTP_DefaultPort), String(port))
|
|
41
|
+
|
|
42
|
+
const rateLimitConfig: GlobalRateLimitConfig = {
|
|
43
|
+
enabled: true,
|
|
44
|
+
windowMs: 1000, // 1 second window for testing
|
|
45
|
+
limit: 3, // Allow only 3 requests
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
proxy = new Proxy(
|
|
49
|
+
{
|
|
50
|
+
routes: [],
|
|
51
|
+
port,
|
|
52
|
+
rateLimit: rateLimitConfig,
|
|
53
|
+
},
|
|
54
|
+
async (event: APIGatewayProxyEvent, context: Context) => {
|
|
55
|
+
context.succeed({
|
|
56
|
+
body: JSON.stringify({ success: true }),
|
|
57
|
+
statusCode: 200,
|
|
58
|
+
})
|
|
59
|
+
}
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
await proxy.load()
|
|
63
|
+
|
|
64
|
+
// First 3 requests should succeed
|
|
65
|
+
for (let i = 0; i < 3; i++) {
|
|
66
|
+
const res = await request(url).get('/test').expect(200)
|
|
67
|
+
c_expect(res.body).to.deep.equal({ success: true })
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// 4th request should be rate limited
|
|
71
|
+
const rateLimitedRes = await request(url).get('/test').expect(429)
|
|
72
|
+
c_expect(rateLimitedRes.body).to.have.property('error', 'rate_limit_exceeded')
|
|
73
|
+
c_expect(rateLimitedRes.body).to.have.property('message')
|
|
74
|
+
|
|
75
|
+
// Wait for window to reset
|
|
76
|
+
await new Promise(resolve => setTimeout(resolve, 1100))
|
|
77
|
+
|
|
78
|
+
// Should work again after window reset
|
|
79
|
+
const afterResetRes = await request(url).get('/test').expect(200)
|
|
80
|
+
c_expect(afterResetRes.body).to.deep.equal({ success: true })
|
|
81
|
+
}, 10000)
|
|
82
|
+
|
|
83
|
+
test('Rate limit headers are present', async () => {
|
|
84
|
+
const port = 57002
|
|
85
|
+
const url = defaultUrl.replace(String(Globals.Listener_HTTP_DefaultPort), String(port))
|
|
86
|
+
|
|
87
|
+
const rateLimitConfig: GlobalRateLimitConfig = {
|
|
88
|
+
enabled: true,
|
|
89
|
+
windowMs: 60000,
|
|
90
|
+
limit: 10,
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
proxy = new Proxy(
|
|
94
|
+
{
|
|
95
|
+
routes: [],
|
|
96
|
+
port,
|
|
97
|
+
rateLimit: rateLimitConfig,
|
|
98
|
+
},
|
|
99
|
+
async (event: APIGatewayProxyEvent, context: Context) => {
|
|
100
|
+
context.succeed({
|
|
101
|
+
body: JSON.stringify({ success: true }),
|
|
102
|
+
statusCode: 200,
|
|
103
|
+
})
|
|
104
|
+
}
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
await proxy.load()
|
|
108
|
+
|
|
109
|
+
const res = await request(url).get('/test').expect(200)
|
|
110
|
+
|
|
111
|
+
// Check for rate limit headers
|
|
112
|
+
c_expect(res.headers).to.have.property('ratelimit-limit')
|
|
113
|
+
c_expect(res.headers).to.have.property('ratelimit-remaining')
|
|
114
|
+
c_expect(res.headers).to.have.property('ratelimit-reset')
|
|
115
|
+
|
|
116
|
+
// Verify header values
|
|
117
|
+
c_expect(res.headers['ratelimit-limit']).to.equal('10')
|
|
118
|
+
c_expect(parseInt(res.headers['ratelimit-remaining'])).to.be.at.most(9)
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
test('Rate limit can be disabled', async () => {
|
|
122
|
+
const port = 57003
|
|
123
|
+
const url = defaultUrl.replace(String(Globals.Listener_HTTP_DefaultPort), String(port))
|
|
124
|
+
|
|
125
|
+
const rateLimitConfig: GlobalRateLimitConfig = {
|
|
126
|
+
enabled: false,
|
|
127
|
+
windowMs: 100,
|
|
128
|
+
limit: 2,
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
proxy = new Proxy(
|
|
132
|
+
{
|
|
133
|
+
routes: [],
|
|
134
|
+
port,
|
|
135
|
+
rateLimit: rateLimitConfig,
|
|
136
|
+
},
|
|
137
|
+
async (event: APIGatewayProxyEvent, context: Context) => {
|
|
138
|
+
context.succeed({
|
|
139
|
+
body: JSON.stringify({ success: true }),
|
|
140
|
+
statusCode: 200,
|
|
141
|
+
})
|
|
142
|
+
}
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
await proxy.load()
|
|
146
|
+
|
|
147
|
+
// Should be able to make many requests without hitting limit
|
|
148
|
+
for (let i = 0; i < 5; i++) {
|
|
149
|
+
const res = await request(url).get('/test').expect(200)
|
|
150
|
+
c_expect(res.body).to.deep.equal({ success: true })
|
|
151
|
+
}
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
test('Rate limit with custom handler', async () => {
|
|
155
|
+
const port = 57004
|
|
156
|
+
const url = defaultUrl.replace(String(Globals.Listener_HTTP_DefaultPort), String(port))
|
|
157
|
+
let customHandlerCalled = false
|
|
158
|
+
|
|
159
|
+
const rateLimitConfig: GlobalRateLimitConfig = {
|
|
160
|
+
enabled: true,
|
|
161
|
+
windowMs: 1000,
|
|
162
|
+
limit: 2,
|
|
163
|
+
handler: (req, res) => {
|
|
164
|
+
customHandlerCalled = true
|
|
165
|
+
res.status(429).json({
|
|
166
|
+
custom: true,
|
|
167
|
+
message: 'Custom rate limit message',
|
|
168
|
+
})
|
|
169
|
+
},
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
proxy = new Proxy(
|
|
173
|
+
{
|
|
174
|
+
routes: [],
|
|
175
|
+
port,
|
|
176
|
+
rateLimit: rateLimitConfig,
|
|
177
|
+
},
|
|
178
|
+
async (event: APIGatewayProxyEvent, context: Context) => {
|
|
179
|
+
context.succeed({
|
|
180
|
+
body: JSON.stringify({ success: true }),
|
|
181
|
+
statusCode: 200,
|
|
182
|
+
})
|
|
183
|
+
}
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
await proxy.load()
|
|
187
|
+
|
|
188
|
+
// First 2 requests succeed
|
|
189
|
+
await request(url).get('/test').expect(200)
|
|
190
|
+
await request(url).get('/test').expect(200)
|
|
191
|
+
|
|
192
|
+
// 3rd request hits custom handler
|
|
193
|
+
const rateLimitedRes = await request(url).get('/test').expect(429)
|
|
194
|
+
c_expect(customHandlerCalled).to.be.true
|
|
195
|
+
c_expect(rateLimitedRes.body).to.have.property('custom', true)
|
|
196
|
+
c_expect(rateLimitedRes.body).to.have.property('message', 'Custom rate limit message')
|
|
197
|
+
}, 10000)
|
|
198
|
+
|
|
199
|
+
test('Rate limit with skip function', async () => {
|
|
200
|
+
const port = 57005
|
|
201
|
+
const url = defaultUrl.replace(String(Globals.Listener_HTTP_DefaultPort), String(port))
|
|
202
|
+
|
|
203
|
+
const rateLimitConfig: GlobalRateLimitConfig = {
|
|
204
|
+
enabled: true,
|
|
205
|
+
windowMs: 1000,
|
|
206
|
+
limit: 2,
|
|
207
|
+
skip: req => {
|
|
208
|
+
// Skip rate limiting for requests with special header
|
|
209
|
+
return req.headers['x-skip-rate-limit'] === 'true'
|
|
210
|
+
},
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
proxy = new Proxy(
|
|
214
|
+
{
|
|
215
|
+
routes: [],
|
|
216
|
+
port,
|
|
217
|
+
rateLimit: rateLimitConfig,
|
|
218
|
+
},
|
|
219
|
+
async (event: APIGatewayProxyEvent, context: Context) => {
|
|
220
|
+
context.succeed({
|
|
221
|
+
body: JSON.stringify({ success: true }),
|
|
222
|
+
statusCode: 200,
|
|
223
|
+
})
|
|
224
|
+
}
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
await proxy.load()
|
|
228
|
+
|
|
229
|
+
// Use up the rate limit
|
|
230
|
+
await request(url).get('/test').expect(200)
|
|
231
|
+
await request(url).get('/test').expect(200)
|
|
232
|
+
|
|
233
|
+
// Normal request should be rate limited
|
|
234
|
+
await request(url).get('/test').expect(429)
|
|
235
|
+
|
|
236
|
+
// Request with skip header should succeed
|
|
237
|
+
const skippedRes = await request(url).get('/test').set('x-skip-rate-limit', 'true').expect(200)
|
|
238
|
+
c_expect(skippedRes.body).to.deep.equal({ success: true })
|
|
239
|
+
}, 10000)
|
|
240
|
+
|
|
241
|
+
test('Rate limit with custom key generator', async () => {
|
|
242
|
+
const port = 57006
|
|
243
|
+
const url = defaultUrl.replace(String(Globals.Listener_HTTP_DefaultPort), String(port))
|
|
244
|
+
|
|
245
|
+
const rateLimitConfig: GlobalRateLimitConfig = {
|
|
246
|
+
enabled: true,
|
|
247
|
+
windowMs: 1000,
|
|
248
|
+
limit: 2,
|
|
249
|
+
keyGenerator: req => {
|
|
250
|
+
// Use custom header for rate limiting key instead of IP
|
|
251
|
+
return (req.headers['x-user-id'] as string) || 'anonymous'
|
|
252
|
+
},
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
proxy = new Proxy(
|
|
256
|
+
{
|
|
257
|
+
routes: [],
|
|
258
|
+
port,
|
|
259
|
+
rateLimit: rateLimitConfig,
|
|
260
|
+
},
|
|
261
|
+
async (event: APIGatewayProxyEvent, context: Context) => {
|
|
262
|
+
context.succeed({
|
|
263
|
+
body: JSON.stringify({ success: true }),
|
|
264
|
+
statusCode: 200,
|
|
265
|
+
})
|
|
266
|
+
}
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
await proxy.load()
|
|
270
|
+
|
|
271
|
+
// User 1 makes 2 requests (uses up their limit)
|
|
272
|
+
await request(url).get('/test').set('x-user-id', 'user1').expect(200)
|
|
273
|
+
await request(url).get('/test').set('x-user-id', 'user1').expect(200)
|
|
274
|
+
|
|
275
|
+
// User 1's 3rd request should be rate limited
|
|
276
|
+
await request(url).get('/test').set('x-user-id', 'user1').expect(429)
|
|
277
|
+
|
|
278
|
+
// User 2 should still have their own limit
|
|
279
|
+
const user2Res = await request(url).get('/test').set('x-user-id', 'user2').expect(200)
|
|
280
|
+
c_expect(user2Res.body).to.deep.equal({ success: true })
|
|
281
|
+
}, 10000)
|
|
282
|
+
|
|
283
|
+
test('Rate limit without config does not apply limiting', async () => {
|
|
284
|
+
const port = 57007
|
|
285
|
+
const url = defaultUrl.replace(String(Globals.Listener_HTTP_DefaultPort), String(port))
|
|
286
|
+
|
|
287
|
+
proxy = new Proxy(
|
|
288
|
+
{
|
|
289
|
+
routes: [],
|
|
290
|
+
port,
|
|
291
|
+
// No rateLimit config
|
|
292
|
+
},
|
|
293
|
+
async (event: APIGatewayProxyEvent, context: Context) => {
|
|
294
|
+
context.succeed({
|
|
295
|
+
body: JSON.stringify({ success: true }),
|
|
296
|
+
statusCode: 200,
|
|
297
|
+
})
|
|
298
|
+
}
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
await proxy.load()
|
|
302
|
+
|
|
303
|
+
// Should be able to make many requests
|
|
304
|
+
for (let i = 0; i < 10; i++) {
|
|
305
|
+
const res = await request(url).get('/test').expect(200)
|
|
306
|
+
c_expect(res.body).to.deep.equal({ success: true })
|
|
307
|
+
}
|
|
308
|
+
})
|
|
309
|
+
|
|
310
|
+
test('Multiple requests from different IPs have separate limits', async () => {
|
|
311
|
+
const port = 57008
|
|
312
|
+
const url = defaultUrl.replace(String(Globals.Listener_HTTP_DefaultPort), String(port))
|
|
313
|
+
|
|
314
|
+
const rateLimitConfig: GlobalRateLimitConfig = {
|
|
315
|
+
enabled: true,
|
|
316
|
+
windowMs: 1000,
|
|
317
|
+
limit: 1, // Very strict: only 1 request per window
|
|
318
|
+
keyGenerator: req => {
|
|
319
|
+
// Use x-forwarded-for header to simulate different IPs
|
|
320
|
+
return (req.headers['x-forwarded-for'] as string) || req.ip || 'unknown'
|
|
321
|
+
},
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
proxy = new Proxy(
|
|
325
|
+
{
|
|
326
|
+
routes: [],
|
|
327
|
+
port,
|
|
328
|
+
rateLimit: rateLimitConfig,
|
|
329
|
+
},
|
|
330
|
+
async (event: APIGatewayProxyEvent, context: Context) => {
|
|
331
|
+
context.succeed({
|
|
332
|
+
body: JSON.stringify({ success: true }),
|
|
333
|
+
statusCode: 200,
|
|
334
|
+
})
|
|
335
|
+
}
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
await proxy.load()
|
|
339
|
+
|
|
340
|
+
// IP1 makes a request (uses their limit)
|
|
341
|
+
await request(url).get('/test').set('x-forwarded-for', '192.168.1.1').expect(200)
|
|
342
|
+
|
|
343
|
+
// IP1's second request should be rate limited
|
|
344
|
+
await request(url).get('/test').set('x-forwarded-for', '192.168.1.1').expect(429)
|
|
345
|
+
|
|
346
|
+
// IP2 should still have their own limit available
|
|
347
|
+
const ip2Res = await request(url).get('/test').set('x-forwarded-for', '192.168.1.2').expect(200)
|
|
348
|
+
c_expect(ip2Res.body).to.deep.equal({ success: true })
|
|
349
|
+
|
|
350
|
+
// IP2's second request should also be rate limited
|
|
351
|
+
await request(url).get('/test').set('x-forwarded-for', '192.168.1.2').expect(429)
|
|
352
|
+
}, 10000)
|
|
353
|
+
|
|
354
|
+
test('Rate limit with containerSetupHook', async () => {
|
|
355
|
+
const port = 57009
|
|
356
|
+
const url = defaultUrl.replace(String(Globals.Listener_HTTP_DefaultPort), String(port))
|
|
357
|
+
let hookCalled = false
|
|
358
|
+
|
|
359
|
+
const rateLimitConfig: GlobalRateLimitConfig = {
|
|
360
|
+
enabled: true,
|
|
361
|
+
windowMs: 1000,
|
|
362
|
+
limit: 5,
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
proxy = new Proxy(
|
|
366
|
+
{
|
|
367
|
+
routes: [],
|
|
368
|
+
port,
|
|
369
|
+
rateLimit: rateLimitConfig,
|
|
370
|
+
containerSetupHook: async (listener, app) => {
|
|
371
|
+
hookCalled = true
|
|
372
|
+
// Hook can be used to add custom middleware or configure the server
|
|
373
|
+
c_expect(listener).to.exist
|
|
374
|
+
c_expect(app).to.exist
|
|
375
|
+
},
|
|
376
|
+
},
|
|
377
|
+
async (event: APIGatewayProxyEvent, context: Context) => {
|
|
378
|
+
context.succeed({
|
|
379
|
+
body: JSON.stringify({ success: true }),
|
|
380
|
+
statusCode: 200,
|
|
381
|
+
})
|
|
382
|
+
}
|
|
383
|
+
)
|
|
384
|
+
|
|
385
|
+
await proxy.load()
|
|
386
|
+
|
|
387
|
+
// Verify hook was called
|
|
388
|
+
c_expect(hookCalled).to.be.true
|
|
389
|
+
|
|
390
|
+
// Verify rate limiting still works after hook
|
|
391
|
+
const res = await request(url).get('/test').expect(200)
|
|
392
|
+
c_expect(res.body).to.deep.equal({ success: true })
|
|
393
|
+
|
|
394
|
+
// Check rate limit headers are present
|
|
395
|
+
c_expect(res.headers).to.have.property('ratelimit-limit')
|
|
396
|
+
})
|
|
397
|
+
|
|
398
|
+
test('Rate limit with Redis store configuration', async () => {
|
|
399
|
+
const port = 57010
|
|
400
|
+
|
|
401
|
+
// Capture console logs to verify Redis store is configured
|
|
402
|
+
const originalLog = console.log
|
|
403
|
+
const logs: string[] = []
|
|
404
|
+
console.log = (...args: any[]) => {
|
|
405
|
+
logs.push(args.join(' '))
|
|
406
|
+
originalLog.apply(console, args)
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// Mock Redis client with proper typing
|
|
410
|
+
const mockRedisClient: any = {
|
|
411
|
+
sendCommand: jest.fn(async () => 'OK'),
|
|
412
|
+
connect: jest.fn(async () => undefined),
|
|
413
|
+
disconnect: jest.fn(async () => undefined),
|
|
414
|
+
isOpen: true,
|
|
415
|
+
isReady: true,
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
const rateLimitConfig: GlobalRateLimitConfig = {
|
|
419
|
+
enabled: true,
|
|
420
|
+
windowMs: 1000,
|
|
421
|
+
limit: 3,
|
|
422
|
+
store: 'redis',
|
|
423
|
+
redis: {
|
|
424
|
+
client: mockRedisClient as any,
|
|
425
|
+
prefix: 'test:rl:',
|
|
426
|
+
},
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
proxy = new Proxy(
|
|
430
|
+
{
|
|
431
|
+
routes: [],
|
|
432
|
+
port,
|
|
433
|
+
rateLimit: rateLimitConfig,
|
|
434
|
+
},
|
|
435
|
+
async (event: APIGatewayProxyEvent, context: Context) => {
|
|
436
|
+
context.succeed({
|
|
437
|
+
body: JSON.stringify({ success: true }),
|
|
438
|
+
statusCode: 200,
|
|
439
|
+
})
|
|
440
|
+
}
|
|
441
|
+
)
|
|
442
|
+
|
|
443
|
+
await proxy.load()
|
|
444
|
+
|
|
445
|
+
// Restore console.log
|
|
446
|
+
console.log = originalLog
|
|
447
|
+
|
|
448
|
+
// Verify Redis store logging message
|
|
449
|
+
const redisStoreLog = logs.find(log => log.includes('[RATE-LIMIT] - Using Redis store'))
|
|
450
|
+
c_expect(redisStoreLog).to.exist
|
|
451
|
+
|
|
452
|
+
// Verify rate limiting is enabled
|
|
453
|
+
const rateLimitEnabledLog = logs.find(log =>
|
|
454
|
+
log.includes('[RATE-LIMIT] - Global rate limiting enabled')
|
|
455
|
+
)
|
|
456
|
+
c_expect(rateLimitEnabledLog).to.exist
|
|
457
|
+
|
|
458
|
+
// Note: We don't make actual requests in this test because the mock Redis client
|
|
459
|
+
// doesn't fully implement the RedisStore interface. This test verifies that:
|
|
460
|
+
// 1. Redis store configuration is recognized
|
|
461
|
+
// 2. Correct logging messages are emitted
|
|
462
|
+
// 3. The proxy loads successfully with Redis config
|
|
463
|
+
})
|
|
464
|
+
|
|
465
|
+
test('Rate limit logs indicate store type', async () => {
|
|
466
|
+
const port = 57011
|
|
467
|
+
const url = defaultUrl.replace(String(Globals.Listener_HTTP_DefaultPort), String(port))
|
|
468
|
+
|
|
469
|
+
// Capture console logs
|
|
470
|
+
const originalLog = console.log
|
|
471
|
+
const logs: string[] = []
|
|
472
|
+
console.log = (...args: any[]) => {
|
|
473
|
+
logs.push(args.join(' '))
|
|
474
|
+
originalLog.apply(console, args)
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
const rateLimitConfig: GlobalRateLimitConfig = {
|
|
478
|
+
enabled: true,
|
|
479
|
+
windowMs: 1000,
|
|
480
|
+
limit: 5,
|
|
481
|
+
store: 'memory', // Explicitly set to memory
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
proxy = new Proxy(
|
|
485
|
+
{
|
|
486
|
+
routes: [],
|
|
487
|
+
port,
|
|
488
|
+
rateLimit: rateLimitConfig,
|
|
489
|
+
},
|
|
490
|
+
async (event: APIGatewayProxyEvent, context: Context) => {
|
|
491
|
+
context.succeed({
|
|
492
|
+
body: JSON.stringify({ success: true }),
|
|
493
|
+
statusCode: 200,
|
|
494
|
+
})
|
|
495
|
+
}
|
|
496
|
+
)
|
|
497
|
+
|
|
498
|
+
await proxy.load()
|
|
499
|
+
|
|
500
|
+
// Restore console.log
|
|
501
|
+
console.log = originalLog
|
|
502
|
+
|
|
503
|
+
// Verify logging messages
|
|
504
|
+
const rateLimitEnabledLog = logs.find(log =>
|
|
505
|
+
log.includes('[RATE-LIMIT] - Global rate limiting enabled')
|
|
506
|
+
)
|
|
507
|
+
const memoryStoreLog = logs.find(log => log.includes('[RATE-LIMIT] - Using in-memory store'))
|
|
508
|
+
|
|
509
|
+
c_expect(rateLimitEnabledLog).to.exist
|
|
510
|
+
c_expect(memoryStoreLog).to.exist
|
|
511
|
+
|
|
512
|
+
// Make a request to verify it works
|
|
513
|
+
const res = await request(url).get('/test').expect(200)
|
|
514
|
+
c_expect(res.body).to.deep.equal({ success: true })
|
|
515
|
+
})
|
|
516
|
+
|
|
517
|
+
test('Rate limit uses default keyGenerator with IP fallback chain', async () => {
|
|
518
|
+
const port = 57012
|
|
519
|
+
const url = defaultUrl.replace(String(Globals.Listener_HTTP_DefaultPort), String(port))
|
|
520
|
+
|
|
521
|
+
// Don't provide a custom keyGenerator - should use default
|
|
522
|
+
const rateLimitConfig: GlobalRateLimitConfig = {
|
|
523
|
+
enabled: true,
|
|
524
|
+
windowMs: 1000,
|
|
525
|
+
limit: 2,
|
|
526
|
+
// NO keyGenerator provided - will use default IP-based logic
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
proxy = new Proxy(
|
|
530
|
+
{
|
|
531
|
+
routes: [],
|
|
532
|
+
port,
|
|
533
|
+
rateLimit: rateLimitConfig,
|
|
534
|
+
},
|
|
535
|
+
async (event: APIGatewayProxyEvent, context: Context) => {
|
|
536
|
+
context.succeed({
|
|
537
|
+
body: JSON.stringify({ success: true }),
|
|
538
|
+
statusCode: 200,
|
|
539
|
+
})
|
|
540
|
+
}
|
|
541
|
+
)
|
|
542
|
+
|
|
543
|
+
await proxy.load()
|
|
544
|
+
|
|
545
|
+
// Make requests without x-forwarded-for header
|
|
546
|
+
// Should use req.ip or req.socket.remoteAddress
|
|
547
|
+
const res1 = await request(url).get('/test').expect(200)
|
|
548
|
+
c_expect(res1.body).to.deep.equal({ success: true })
|
|
549
|
+
|
|
550
|
+
const res2 = await request(url).get('/test').expect(200)
|
|
551
|
+
c_expect(res2.body).to.deep.equal({ success: true })
|
|
552
|
+
|
|
553
|
+
// 3rd request should be rate limited (covers default handler lines 208-209)
|
|
554
|
+
const res3 = await request(url).get('/test').expect(429)
|
|
555
|
+
c_expect(res3.body).to.have.property('error', 'rate_limit_exceeded')
|
|
556
|
+
c_expect(res3.body).to.have.property('message', 'Too many requests. Please try again later.')
|
|
557
|
+
}, 10000)
|
|
558
|
+
|
|
559
|
+
test('Rate limit default keyGenerator uses x-forwarded-for', async () => {
|
|
560
|
+
const port = 57013
|
|
561
|
+
const url = defaultUrl.replace(String(Globals.Listener_HTTP_DefaultPort), String(port))
|
|
562
|
+
|
|
563
|
+
// Don't provide a custom keyGenerator
|
|
564
|
+
const rateLimitConfig: GlobalRateLimitConfig = {
|
|
565
|
+
enabled: true,
|
|
566
|
+
windowMs: 1000,
|
|
567
|
+
limit: 1, // Very strict
|
|
568
|
+
// NO keyGenerator - uses default with x-forwarded-for fallback
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
proxy = new Proxy(
|
|
572
|
+
{
|
|
573
|
+
routes: [],
|
|
574
|
+
port,
|
|
575
|
+
rateLimit: rateLimitConfig,
|
|
576
|
+
},
|
|
577
|
+
async (event: APIGatewayProxyEvent, context: Context) => {
|
|
578
|
+
context.succeed({
|
|
579
|
+
body: JSON.stringify({ success: true }),
|
|
580
|
+
statusCode: 200,
|
|
581
|
+
})
|
|
582
|
+
}
|
|
583
|
+
)
|
|
584
|
+
|
|
585
|
+
await proxy.load()
|
|
586
|
+
|
|
587
|
+
// Test with x-forwarded-for header (covers line 197-198)
|
|
588
|
+
const res1 = await request(url)
|
|
589
|
+
.get('/test')
|
|
590
|
+
.set('x-forwarded-for', '10.0.0.1, 10.0.0.2')
|
|
591
|
+
.expect(200)
|
|
592
|
+
c_expect(res1.body).to.deep.equal({ success: true })
|
|
593
|
+
|
|
594
|
+
// Second request from same IP should be rate limited
|
|
595
|
+
const res2 = await request(url)
|
|
596
|
+
.get('/test')
|
|
597
|
+
.set('x-forwarded-for', '10.0.0.1, 10.0.0.3')
|
|
598
|
+
.expect(429)
|
|
599
|
+
c_expect(res2.body).to.have.property('error', 'rate_limit_exceeded')
|
|
600
|
+
|
|
601
|
+
// Wait for window to reset
|
|
602
|
+
await new Promise(resolve => setTimeout(resolve, 1100))
|
|
603
|
+
|
|
604
|
+
// Different IP should work (covers the split and trim logic)
|
|
605
|
+
const res3 = await request(url).get('/test').set('x-forwarded-for', '10.0.0.99').expect(200)
|
|
606
|
+
c_expect(res3.body).to.deep.equal({ success: true })
|
|
607
|
+
}, 10000)
|
|
608
|
+
|
|
609
|
+
test('Rate limit default handler with warning log', async () => {
|
|
610
|
+
const port = 57014
|
|
611
|
+
const url = defaultUrl.replace(String(Globals.Listener_HTTP_DefaultPort), String(port))
|
|
612
|
+
|
|
613
|
+
// Capture console.warn to verify default handler logging
|
|
614
|
+
const originalWarn = console.warn
|
|
615
|
+
const warnings: any[] = []
|
|
616
|
+
console.warn = (...args: any[]) => {
|
|
617
|
+
warnings.push(args)
|
|
618
|
+
originalWarn.apply(console, args)
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
// Don't provide custom handler - should use default
|
|
622
|
+
const rateLimitConfig: GlobalRateLimitConfig = {
|
|
623
|
+
enabled: true,
|
|
624
|
+
windowMs: 1000,
|
|
625
|
+
limit: 1,
|
|
626
|
+
// NO custom handler - will use default (lines 208-209)
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
proxy = new Proxy(
|
|
630
|
+
{
|
|
631
|
+
routes: [],
|
|
632
|
+
port,
|
|
633
|
+
rateLimit: rateLimitConfig,
|
|
634
|
+
},
|
|
635
|
+
async (event: APIGatewayProxyEvent, context: Context) => {
|
|
636
|
+
context.succeed({
|
|
637
|
+
body: JSON.stringify({ success: true }),
|
|
638
|
+
statusCode: 200,
|
|
639
|
+
})
|
|
640
|
+
}
|
|
641
|
+
)
|
|
642
|
+
|
|
643
|
+
await proxy.load()
|
|
644
|
+
|
|
645
|
+
// Use up the limit
|
|
646
|
+
await request(url).get('/test-endpoint').expect(200)
|
|
647
|
+
|
|
648
|
+
// Trigger rate limit (should call default handler and log warning)
|
|
649
|
+
const rateLimitedRes = await request(url).get('/test-endpoint').expect(429)
|
|
650
|
+
|
|
651
|
+
// Restore console.warn
|
|
652
|
+
console.warn = originalWarn
|
|
653
|
+
|
|
654
|
+
// Verify default handler was called (lines 208-209, 220-225)
|
|
655
|
+
c_expect(rateLimitedRes.body).to.deep.equal({
|
|
656
|
+
error: 'rate_limit_exceeded',
|
|
657
|
+
message: 'Too many requests. Please try again later.',
|
|
658
|
+
})
|
|
659
|
+
|
|
660
|
+
// Verify warning was logged
|
|
661
|
+
const rateLimitWarning = warnings.find(w => w[0] === '[Proxy] - [RATE-LIMIT] - Limit exceeded')
|
|
662
|
+
c_expect(rateLimitWarning).to.exist
|
|
663
|
+
c_expect(rateLimitWarning[1]).to.have.property('path', '/test-endpoint')
|
|
664
|
+
c_expect(rateLimitWarning[1]).to.have.property('method', 'GET')
|
|
665
|
+
c_expect(rateLimitWarning[1]).to.have.property('timestamp')
|
|
666
|
+
}, 10000)
|
|
667
|
+
|
|
668
|
+
test('Rate limit with Redis store sendCommand and prefix', async () => {
|
|
669
|
+
const port = 57015
|
|
670
|
+
|
|
671
|
+
// Capture console logs
|
|
672
|
+
const originalLog = console.log
|
|
673
|
+
const logs: string[] = []
|
|
674
|
+
console.log = (...args: any[]) => {
|
|
675
|
+
logs.push(args.join(' '))
|
|
676
|
+
originalLog.apply(console, args)
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
// Mock Redis client
|
|
680
|
+
const sendCommandCalls: any[] = []
|
|
681
|
+
const mockRedisClient: any = {
|
|
682
|
+
sendCommand: jest.fn(async (...args: any[]) => {
|
|
683
|
+
sendCommandCalls.push(args)
|
|
684
|
+
return 'OK'
|
|
685
|
+
}),
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
// Test with custom prefix (covers lines 248-252)
|
|
689
|
+
const rateLimitConfig: GlobalRateLimitConfig = {
|
|
690
|
+
enabled: true,
|
|
691
|
+
windowMs: 1000,
|
|
692
|
+
limit: 3,
|
|
693
|
+
store: 'redis',
|
|
694
|
+
redis: {
|
|
695
|
+
client: mockRedisClient,
|
|
696
|
+
prefix: 'custom:prefix:', // Custom prefix (line 252)
|
|
697
|
+
},
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
proxy = new Proxy(
|
|
701
|
+
{
|
|
702
|
+
routes: [],
|
|
703
|
+
port,
|
|
704
|
+
rateLimit: rateLimitConfig,
|
|
705
|
+
},
|
|
706
|
+
async (event: APIGatewayProxyEvent, context: Context) => {
|
|
707
|
+
context.succeed({
|
|
708
|
+
body: JSON.stringify({ success: true }),
|
|
709
|
+
statusCode: 200,
|
|
710
|
+
})
|
|
711
|
+
}
|
|
712
|
+
)
|
|
713
|
+
|
|
714
|
+
await proxy.load()
|
|
715
|
+
|
|
716
|
+
// Restore console.log
|
|
717
|
+
console.log = originalLog
|
|
718
|
+
|
|
719
|
+
// Verify Redis store was created with correct logging (line 249)
|
|
720
|
+
const redisLog = logs.find(log => log.includes('[RATE-LIMIT] - Using Redis store'))
|
|
721
|
+
c_expect(redisLog).to.exist
|
|
722
|
+
|
|
723
|
+
// Verify RedisStore was instantiated (lines 250-252)
|
|
724
|
+
// The store object should have been created with sendCommand and prefix
|
|
725
|
+
// We can't directly test the RedisStore internals, but we verified:
|
|
726
|
+
// 1. Redis store logging message appears
|
|
727
|
+
// 2. The configuration is accepted without errors
|
|
728
|
+
// 3. Proxy loads successfully with Redis config
|
|
729
|
+
})
|
|
730
|
+
|
|
731
|
+
test('Rate limit with Redis store default prefix', async () => {
|
|
732
|
+
const port = 57016
|
|
733
|
+
|
|
734
|
+
const mockRedisClient: any = {
|
|
735
|
+
sendCommand: jest.fn(async () => 'OK'),
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
// Don't provide prefix - should use default 'wapi:rl:' (line 252)
|
|
739
|
+
const rateLimitConfig: GlobalRateLimitConfig = {
|
|
740
|
+
enabled: true,
|
|
741
|
+
windowMs: 1000,
|
|
742
|
+
limit: 5,
|
|
743
|
+
store: 'redis',
|
|
744
|
+
redis: {
|
|
745
|
+
client: mockRedisClient,
|
|
746
|
+
// NO prefix - should default to 'wapi:rl:' (line 252)
|
|
747
|
+
},
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
proxy = new Proxy(
|
|
751
|
+
{
|
|
752
|
+
routes: [],
|
|
753
|
+
port,
|
|
754
|
+
rateLimit: rateLimitConfig,
|
|
755
|
+
},
|
|
756
|
+
async (event: APIGatewayProxyEvent, context: Context) => {
|
|
757
|
+
context.succeed({
|
|
758
|
+
body: JSON.stringify({ success: true }),
|
|
759
|
+
statusCode: 200,
|
|
760
|
+
})
|
|
761
|
+
}
|
|
762
|
+
)
|
|
763
|
+
|
|
764
|
+
// Should load successfully with default prefix
|
|
765
|
+
await proxy.load()
|
|
766
|
+
|
|
767
|
+
// Verify proxy is running
|
|
768
|
+
c_expect(proxy).to.exist
|
|
769
|
+
})
|
|
770
|
+
})
|
|
771
|
+
|
|
772
|
+
export {}
|