@creator.co/wapi 1.8.3 → 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.
@@ -1,6 +1,7 @@
1
1
  import { Server as HTTPServer } from 'http'
2
2
 
3
3
  import express from 'express'
4
+ import type { RedisClientType } from 'redis'
4
5
  import { z } from 'zod'
5
6
 
6
7
  import ContainerServer from './lib/ContainerServer.js'
@@ -14,6 +15,46 @@ import Transaction, {
14
15
  } from '../BaseEvent/Transaction.js'
15
16
  import Utils from '../Util/Utils.js'
16
17
 
18
+ /**
19
+ * Configuration options for rate limiting on a specific route.
20
+ * @property {number} [windowMs] - Time window in milliseconds for rate limiting (default: 60000 - 1 minute)
21
+ * @property {number} [limit] - Maximum number of requests allowed per window (default: 60)
22
+ * @property {string} [message] - Custom error message for rate limit exceeded
23
+ * @property {'ip' | 'userId' | ((req: express.Request) => string)} [keyGenerator] - Strategy for generating rate limit keys
24
+ */
25
+ export interface RateLimitConfig {
26
+ windowMs?: number
27
+ limit?: number
28
+ message?: string
29
+ keyGenerator?: 'ip' | 'userId' | ((req: express.Request) => string)
30
+ skip?: (req: express.Request) => boolean
31
+ }
32
+
33
+ /**
34
+ * Global rate limiting configuration for the router.
35
+ * @property {boolean} [enabled] - Whether rate limiting is enabled (default: true if config provided)
36
+ * @property {number} [windowMs] - Time window in milliseconds (default: 60000 - 1 minute)
37
+ * @property {number} [limit] - Maximum requests per window per key (default: 60)
38
+ * @property {(req: express.Request) => string} [keyGenerator] - Function to generate rate limit key (default: IP-based)
39
+ * @property {(req: express.Request, res: express.Response) => void} [handler] - Custom handler for rate limit exceeded
40
+ * @property {(req: express.Request) => boolean} [skip] - Function to skip rate limiting for certain requests
41
+ * @property {'memory' | 'redis'} [store] - Storage backend for rate limit data
42
+ * @property {object} [redis] - Redis configuration when using Redis store
43
+ */
44
+ export interface GlobalRateLimitConfig {
45
+ enabled?: boolean
46
+ windowMs?: number
47
+ limit?: number
48
+ keyGenerator?: (req: express.Request) => string
49
+ handler?: (req: express.Request, res: express.Response) => void
50
+ skip?: (req: express.Request) => boolean
51
+ store?: 'memory' | 'redis'
52
+ redis?: {
53
+ client: RedisClientType
54
+ prefix?: string
55
+ }
56
+ }
57
+
17
58
  /**
18
59
  * Represents a route in an API.
19
60
  * @template InputType - The type of the input data for the route.
@@ -94,6 +135,13 @@ export interface Route<
94
135
  [key: string]: string[] | never[]
95
136
  }[]
96
137
  }
138
+
139
+ /**
140
+ * Optional rate limiting configuration for this specific route.
141
+ * Set to `false` to disable global rate limiting for this route.
142
+ * @type {RateLimitConfig | false}
143
+ */
144
+ rateLimit?: RateLimitConfig | false
97
145
  }
98
146
 
99
147
  export type AnyRoute = Route<any | never, any | never, any | never, any | never>
@@ -147,6 +195,12 @@ export type RouterConfig = TransactionConfig & {
147
195
  * @type {string | undefined}
148
196
  */
149
197
  healthCheckRoute?: string
198
+ /**
199
+ * Global rate limiting configuration for all routes.
200
+ * Individual routes can override this with their own rateLimit config.
201
+ * @type {GlobalRateLimitConfig | undefined}
202
+ */
203
+ rateLimit?: GlobalRateLimitConfig
150
204
  containerSetupHook?: (server: HTTPServer, app: express.Express) => Promise<void>
151
205
  }
152
206
 
@@ -3,13 +3,15 @@ import { Server as HTTPServer, createServer } from 'http'
3
3
 
4
4
  import cors from 'cors'
5
5
  import express from 'express'
6
+ import { rateLimit } from 'express-rate-limit'
7
+ import { RedisStore } from 'rate-limit-redis'
6
8
 
7
9
  import Server from './../Server.js'
8
10
  import GenericHandler from './GenericHandler.js'
9
11
  import HealthHandler from './HealthHandler.js'
10
12
  import Globals from '../../../Globals.js'
11
13
  import Utils from '../../../Util/Utils.js'
12
- import { RouterConfig } from '../../Router.js'
14
+ import { GlobalRateLimitConfig, RouterConfig } from '../../Router.js'
13
15
 
14
16
  /* Get package.json version from Wapi on ESM */
15
17
  const { version: appVersion } = JSON.parse(fs.readFileSync('package.json').toString())
@@ -78,6 +80,13 @@ export default class Proxy {
78
80
  )
79
81
  )
80
82
 
83
+ // Apply global rate limiting if configured
84
+ if (this.config.rateLimit && this.config.rateLimit.enabled !== false) {
85
+ console.log('[Proxy] - [RATE-LIMIT] - Global rate limiting enabled')
86
+ const rateLimitMiddleware = this.createRateLimitMiddleware(this.config.rateLimit)
87
+ this.app.use(rateLimitMiddleware)
88
+ }
89
+
81
90
  // //This supposedly fix some 502 codes where nodejs socket would hang during
82
91
  // //a request and if behind ALB, it would cause 502 codes. Had experiencied this
83
92
  // //and 502 codes reduced dramastically, but still some appearances. Maybe this
@@ -174,4 +183,77 @@ export default class Proxy {
174
183
  //load balancer and we just foward everything we have to the function.
175
184
  this.app.route(Globals.Listener_HTTP_ProxyRoute).all(GenericHandler(this.serverlessHandler))
176
185
  }
186
+
187
+ /**
188
+ * Creates rate limiting middleware based on the provided configuration.
189
+ * @param {GlobalRateLimitConfig} config - The rate limit configuration
190
+ * @returns {express.RequestHandler} Express middleware for rate limiting
191
+ * @private
192
+ */
193
+ private createRateLimitMiddleware(config: GlobalRateLimitConfig): express.RequestHandler {
194
+ const store = this.createRateLimitStore(config)
195
+
196
+ return rateLimit({
197
+ windowMs: config.windowMs || 60000, // Default: 1 minute
198
+ limit: config.limit || 60, // Default: 60 requests per windowMs
199
+ standardHeaders: true, // Return rate limit info in `RateLimit-*` headers
200
+ legacyHeaders: false, // Disable `X-RateLimit-*` headers
201
+
202
+ // Key generator - how to identify unique clients
203
+ keyGenerator:
204
+ config.keyGenerator ||
205
+ ((req: express.Request) => {
206
+ // Use IP address from proxy-aware sources
207
+ return (
208
+ (req.ip as string) ||
209
+ (req.headers['x-forwarded-for'] as string)?.split(',')[0]?.trim() ||
210
+ req.socket.remoteAddress ||
211
+ 'unknown'
212
+ )
213
+ }),
214
+
215
+ // Custom handler when rate limit is exceeded
216
+ handler:
217
+ config.handler ||
218
+ ((req: express.Request, res: express.Response) => {
219
+ // Log rate limit violation
220
+ console.warn('[Proxy] - [RATE-LIMIT] - Limit exceeded', {
221
+ ip: req.ip,
222
+ path: req.path,
223
+ method: req.method,
224
+ timestamp: new Date().toISOString(),
225
+ })
226
+
227
+ res.status(429).json({
228
+ error: 'rate_limit_exceeded',
229
+ message: 'Too many requests. Please try again later.',
230
+ })
231
+ }),
232
+
233
+ // Skip function - allows bypassing rate limiting for certain requests
234
+ skip: config.skip,
235
+
236
+ // Store - use Redis if configured, otherwise in-memory
237
+ store: store,
238
+ })
239
+ }
240
+
241
+ /**
242
+ * Creates the appropriate store for rate limiting based on configuration.
243
+ * @param {GlobalRateLimitConfig} config - The rate limit configuration
244
+ * @returns {RedisStore | undefined} Redis store if configured, undefined for in-memory
245
+ * @private
246
+ */
247
+ private createRateLimitStore(config: GlobalRateLimitConfig): any {
248
+ if (config.store === 'redis' && config.redis?.client) {
249
+ console.log('[Proxy] - [RATE-LIMIT] - Using Redis store')
250
+ return new RedisStore({
251
+ sendCommand: (...args: string[]) => config.redis!.client.sendCommand(args),
252
+ prefix: config.redis.prefix || 'wapi:rl:',
253
+ })
254
+ }
255
+
256
+ console.log('[Proxy] - [RATE-LIMIT] - Using in-memory store')
257
+ return undefined // express-rate-limit uses MemoryStore by default
258
+ }
177
259
  }
package/src/Util/Utils.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  import child_process from 'child_process'
2
+ import { createHash } from 'crypto'
2
3
 
3
4
  import { convertToAttr, marshall, unmarshall } from '@aws-sdk/util-dynamodb'
5
+ import stringify from 'json-stringify-safe'
4
6
 
5
7
  /**
6
8
  * Utility class containing various static methods for common operations.
@@ -129,4 +131,14 @@ export default class Utils {
129
131
  }
130
132
  return item
131
133
  }
134
+
135
+ /**
136
+ * helper that hashes values using SHA-256.
137
+ * @param {unknown} raw - The raw item for conversion.
138
+ * @returns {string} The hashed string.
139
+ */
140
+ public static hashValue(raw: unknown): string {
141
+ const s = typeof raw === 'string' ? raw : stringify(raw)
142
+ return createHash('sha256').update(s).digest('hex')
143
+ }
132
144
  }
@@ -63,7 +63,7 @@ function testLogs(isContainer: boolean, provider?: Logger) {
63
63
  )
64
64
  expect(consoleProxy.log).toHaveBeenNthCalledWith(
65
65
  1,
66
- expect.stringContaining('] TEST {\n "password": "**SUPPRESSED_SENSITIVE_DATA** (3 len)"\n}')
66
+ expect.stringContaining('] TEST {\n "password": "[MASKED]"\n}')
67
67
  )
68
68
  // test if object is not mutate
69
69
  c_expect(object.password).to.be.equals('123')
@@ -80,7 +80,7 @@ function testLogs(isContainer: boolean, provider?: Logger) {
80
80
  expect(consoleProxy.log).toHaveBeenNthCalledWith(1, expect.stringContaining('] TEST'))
81
81
  expect(consoleProxy.log).toHaveBeenNthCalledWith(
82
82
  1,
83
- expect.stringContaining('"password": "**SUPPRESSED_SENSITIVE_DATA** (5 len)"')
83
+ expect.stringContaining('"password": "[MASKED]"')
84
84
  )
85
85
  })
86
86
 
@@ -93,9 +93,7 @@ function testLogs(isContainer: boolean, provider?: Logger) {
93
93
  )
94
94
  expect(consoleProxy.log).toHaveBeenNthCalledWith(
95
95
  1,
96
- expect.stringContaining(
97
- '] {\n "object": {\n "password": "**SUPPRESSED_SENSITIVE_DATA** (3 len)"\n }\n}'
98
- )
96
+ expect.stringContaining('] {\n "object": {\n "password": "[MASKED]"\n }\n}')
99
97
  )
100
98
  })
101
99
 
@@ -108,9 +106,7 @@ function testLogs(isContainer: boolean, provider?: Logger) {
108
106
  )
109
107
  expect(consoleProxy.log).toHaveBeenNthCalledWith(
110
108
  1,
111
- expect.stringContaining(
112
- '] {\n "object": {\n "password": "**SUPPRESSED_SENSITIVE_DATA** (3 len)"\n }\n}'
113
- )
109
+ expect.stringContaining('] {\n "object": {\n "password": "[MASKED]"\n }\n}')
114
110
  )
115
111
  })
116
112
 
@@ -146,12 +142,57 @@ function testLogs(isContainer: boolean, provider?: Logger) {
146
142
  )
147
143
  expect(consoleProxy.log).toHaveBeenNthCalledWith(
148
144
  1,
149
- expect.stringContaining(
150
- '] TEST2 [\n {\n "password": "**SUPPRESSED_SENSITIVE_DATA** (4 len)"\n }\n]'
151
- )
145
+ expect.stringContaining('] TEST2 [\n {\n "password": "[MASKED]"\n }\n]')
146
+ )
147
+ })
148
+ test(`${type} - ${loggerType} Log - Suppress sensitive info (token)`, async () => {
149
+ setContainerFlag(isContainer)
150
+ localProvider.log({ token: 'abc123xyz' })
151
+ expect(consoleProxy.log).toHaveBeenNthCalledWith(
152
+ 1,
153
+ expect.stringContaining((isContainer ? `${transactionID} ` : '') + '[INFO] [Logger.test.ts:')
154
+ )
155
+ expect(consoleProxy.log).toHaveBeenNthCalledWith(
156
+ 1,
157
+ expect.stringContaining('"token": "[HASHED:')
158
+ )
159
+ })
160
+
161
+ test(`${type} - ${loggerType} Log - Suppress sensitive info (key)`, async () => {
162
+ setContainerFlag(isContainer)
163
+ localProvider.log({ key: 'secret-key-123' })
164
+ expect(consoleProxy.log).toHaveBeenNthCalledWith(
165
+ 1,
166
+ expect.stringContaining((isContainer ? `${transactionID} ` : '') + '[INFO] [Logger.test.ts:')
152
167
  )
168
+ expect(consoleProxy.log).toHaveBeenNthCalledWith(1, expect.stringContaining('"key": "[HASHED:'))
153
169
  })
154
170
 
171
+ test(`${type} - ${loggerType} Log - Suppress sensitive info (authorization)`, async () => {
172
+ setContainerFlag(isContainer)
173
+ localProvider.log({ authorization: 'Bearer token123' })
174
+ expect(consoleProxy.log).toHaveBeenNthCalledWith(
175
+ 1,
176
+ expect.stringContaining((isContainer ? `${transactionID} ` : '') + '[INFO] [Logger.test.ts:')
177
+ )
178
+ expect(consoleProxy.log).toHaveBeenNthCalledWith(
179
+ 1,
180
+ expect.stringContaining('"authorization": "Bearer [HASHED:')
181
+ )
182
+ })
183
+
184
+ test(`${type} - ${loggerType} Log - Suppress sensitive info (accounts)`, async () => {
185
+ setContainerFlag(isContainer)
186
+ localProvider.log({ accounts: 'account-data' })
187
+ expect(consoleProxy.log).toHaveBeenNthCalledWith(
188
+ 1,
189
+ expect.stringContaining((isContainer ? `${transactionID} ` : '') + '[INFO] [Logger.test.ts:')
190
+ )
191
+ expect(consoleProxy.log).toHaveBeenNthCalledWith(
192
+ 1,
193
+ expect.stringContaining('"accounts": "**SUPPRESSED_SENSITIVE_DATA** (12 len)"')
194
+ )
195
+ })
155
196
  test(`${type} - ${loggerType} Log - Circular reference (Error)`, async () => {
156
197
  setContainerFlag(isContainer)
157
198
  class SelfRefError extends Error {