@@ -14,8 +14,9 @@ import { TransactionDB } from './db/sqlite.js';
1414import { config } from './config.js' ;
1515
1616const RATE_LIMIT_MAX = 60 ;
17+ const RATE_LIMIT_STRICT_MAX = 10 ;
1718const RATE_LIMIT_WINDOW_MS = 60_000 ;
18- const RATE_LIMIT_CLEANUP_THRESHOLD = 10_000 ;
19+ const RATE_LIMIT_CLEANUP_INTERVAL = 100 ;
1920
2021interface AppDeps {
2122 pricing ?: PricingService ;
@@ -37,23 +38,56 @@ export function createApp(deps?: AppDeps) {
3738
3839 // Rate limiting (simple in-memory)
3940 const rateLimits = new Map < string , { count : number ; resetAt : number } > ( ) ;
40- app . use ( '*' , async ( c , next ) => {
41- const forwarded = c . req . header ( 'x-forwarded-for' ) ;
42- const ip = forwarded ? forwarded . split ( ',' ) . pop ( ) ! . trim ( ) : 'unknown' ;
43- const now = Date . now ( ) ;
44- const entry = rateLimits . get ( ip ) ;
41+ const strictRateLimits = new Map < string , { count : number ; resetAt : number } > ( ) ;
42+ let requestCounter = 0 ;
43+
44+ // First IP in X-Forwarded-For is the original client; subsequent entries
45+ // are proxies that appended themselves. Spoofable, but rate limiting by
46+ // first IP is the standard approach behind a trusted reverse proxy.
47+ function getClientIp ( forwarded : string | undefined ) : string {
48+ if ( ! forwarded ) return 'unknown' ;
49+ const first = forwarded . split ( ',' ) [ 0 ] ?. trim ( ) ;
50+ return first || 'unknown' ;
51+ }
52+
53+ function checkRateLimit (
54+ map : Map < string , { count : number ; resetAt : number } > ,
55+ ip : string ,
56+ max : number ,
57+ now : number ,
58+ ) : boolean {
59+ const entry = map . get ( ip ) ;
4560 if ( entry && entry . resetAt > now ) {
46- if ( entry . count >= RATE_LIMIT_MAX ) {
47- return c . json ( { error : 'Rate limit exceeded' } , 429 ) ;
48- }
61+ if ( entry . count >= max ) return false ;
4962 entry . count ++ ;
5063 } else {
51- rateLimits . set ( ip , { count : 1 , resetAt : now + RATE_LIMIT_WINDOW_MS } ) ;
52- if ( rateLimits . size > RATE_LIMIT_CLEANUP_THRESHOLD ) {
53- for ( const [ key , val ] of rateLimits ) {
54- if ( val . resetAt <= now ) rateLimits . delete ( key ) ;
55- }
56- }
64+ map . set ( ip , { count : 1 , resetAt : now + RATE_LIMIT_WINDOW_MS } ) ;
65+ }
66+ return true ;
67+ }
68+
69+ function evictExpired ( now : number ) {
70+ for ( const [ key , val ] of rateLimits ) {
71+ if ( val . resetAt <= now ) rateLimits . delete ( key ) ;
72+ }
73+ for ( const [ key , val ] of strictRateLimits ) {
74+ if ( val . resetAt <= now ) strictRateLimits . delete ( key ) ;
75+ }
76+ }
77+
78+ app . use ( '*' , async ( c , next ) => {
79+ const ip = getClientIp ( c . req . header ( 'x-forwarded-for' ) ) ;
80+ const now = Date . now ( ) ;
81+
82+ // Periodic cleanup
83+ requestCounter ++ ;
84+ if ( requestCounter % RATE_LIMIT_CLEANUP_INTERVAL === 0 ) {
85+ evictExpired ( now ) ;
86+ }
87+
88+ if ( ! checkRateLimit ( rateLimits , ip , RATE_LIMIT_MAX , now ) ) {
89+ console . warn ( `Rate limit hit: ${ ip } on ${ c . req . path } ` ) ;
90+ return c . json ( { error : 'Rate limit exceeded' } , 429 ) ;
5791 }
5892 await next ( ) ;
5993 } ) ;
@@ -64,7 +98,20 @@ export function createApp(deps?: AppDeps) {
6498 app . route ( '/' , txRoutes ( db ) ) ;
6599
66100 if ( deps ?. treasury && deps ?. x402 ) {
67- app . route ( '/' , treasuryRoutes ( deps . treasury , deps . payout ) ) ;
101+ // Stricter rate limit for state-changing endpoints
102+ const strictPrefixes = [ '/buy' , '/sell' ] ;
103+ app . use ( '*' , async ( c , next ) => {
104+ if ( ! strictPrefixes . some ( ( p ) => c . req . path === p || c . req . path . startsWith ( p + '/' ) ) ) return next ( ) ;
105+ const ip = getClientIp ( c . req . header ( 'x-forwarded-for' ) ) ;
106+ const now = Date . now ( ) ;
107+ if ( ! checkRateLimit ( strictRateLimits , ip , RATE_LIMIT_STRICT_MAX , now ) ) {
108+ console . warn ( `Strict rate limit hit: ${ ip } on ${ c . req . path } ` ) ;
109+ return c . json ( { error : 'Rate limit exceeded' } , 429 ) ;
110+ }
111+ await next ( ) ;
112+ } ) ;
113+
114+ app . route ( '/' , treasuryRoutes ( deps . treasury , deps . payout , deps . x402 ?. facilitator ) ) ;
68115 app . route ( '/' , buyRoutes ( { db, pricing, treasury : deps . treasury , x402 : deps . x402 } ) ) ;
69116 app . route ( '/' , sellRoutes ( { db, pricing, treasuryAddress : deps . treasury . address , payout : deps . payout } ) ) ;
70117 }
0 commit comments