1
1
import { ActionFunction , ActionFunctionArgs , LoaderFunction } from "@remix-run/server-runtime" ;
2
2
import { Ratelimit } from "@upstash/ratelimit" ;
3
+ import { NextFunction } from "express" ;
3
4
import Redis , { RedisOptions } from "ioredis" ;
4
5
import { env } from "~/env.server" ;
6
+ import { Request as ExpressRequest , Response as ExpressResponse } from "express" ;
7
+ import { logger } from "./logger.server" ;
8
+ import { createHash } from "node:crypto" ;
5
9
6
10
function createRedisRateLimitClient (
7
11
redisOptions : RedisOptions
@@ -29,59 +33,121 @@ function createRedisRateLimitClient(
29
33
}
30
34
31
35
type Options = {
36
+ log ?: {
37
+ requests ?: boolean ;
38
+ rejections ?: boolean ;
39
+ } ;
32
40
redis : RedisOptions ;
41
+ keyPrefix : string ;
42
+ pathMatchers : ( RegExp | string ) [ ] ;
33
43
limiter : ConstructorParameters < typeof Ratelimit > [ 0 ] [ "limiter" ] ;
34
44
} ;
35
45
36
- class RateLimitter {
37
- #rateLimitter: Ratelimit ;
46
+ //returns an Express middleware that rate limits using the Bearer token in the Authorization header
47
+ export function authorizationRateLimitMiddleware ( {
48
+ redis,
49
+ keyPrefix,
50
+ limiter,
51
+ pathMatchers,
52
+ log = {
53
+ rejections : true ,
54
+ requests : true ,
55
+ } ,
56
+ } : Options ) {
57
+ const rateLimiter = new Ratelimit ( {
58
+ redis : createRedisRateLimitClient ( redis ) ,
59
+ limiter : limiter ,
60
+ ephemeralCache : new Map ( ) ,
61
+ analytics : false ,
62
+ prefix : keyPrefix ,
63
+ } ) ;
38
64
39
- constructor ( { redis, limiter } : Options ) {
40
- this . #rateLimitter = new Ratelimit ( {
41
- redis : createRedisRateLimitClient ( redis ) ,
42
- limiter : limiter ,
43
- ephemeralCache : new Map ( ) ,
44
- analytics : true ,
45
- } ) ;
46
- }
65
+ return async ( req : ExpressRequest , res : ExpressResponse , next : NextFunction ) => {
66
+ if ( log . requests ) {
67
+ logger . info ( `RateLimitter (${ keyPrefix } ): request to ${ req . path } ` ) ;
68
+ }
47
69
48
- //todo Express middleware
49
- //use the Authentication header with Bearer token
70
+ //first check if any of the pathMatchers match the request path
71
+ const path = req . path ;
72
+ if (
73
+ ! pathMatchers . some ( ( matcher ) =>
74
+ matcher instanceof RegExp ? matcher . test ( path ) : path === matcher
75
+ )
76
+ ) {
77
+ if ( log . requests ) {
78
+ logger . info ( `RateLimitter (${ keyPrefix } ): didn't match ${ req . path } ` ) ;
79
+ }
80
+ return next ( ) ;
81
+ }
50
82
51
- async loader ( key : string , fn : LoaderFunction ) : Promise < ReturnType < LoaderFunction > > {
52
- const { success, pending, limit, reset, remaining } = await this . #rateLimitter. limit (
53
- `ratelimit:${ key } `
54
- ) ;
83
+ if ( log . requests ) {
84
+ logger . info ( `RateLimitter (${ keyPrefix } ): matched ${ req . path } ` ) ;
85
+ }
55
86
56
- if ( success ) {
57
- return fn ;
87
+ const authorizationValue = req . headers . authorization ;
88
+ if ( ! authorizationValue ) {
89
+ if ( log . requests ) {
90
+ logger . info ( `RateLimitter (${ keyPrefix } ): no key` ) ;
91
+ }
92
+ return res . status ( 401 ) . send ( "Unauthorized" ) ;
58
93
}
59
94
60
- const response = new Response ( "Rate limit exceeded" , { status : 429 } ) ;
61
- response . headers . set ( "X-RateLimit-Limit" , limit . toString ( ) ) ;
62
- response . headers . set ( "X-RateLimit-Remaining" , remaining . toString ( ) ) ;
63
- response . headers . set ( "X-RateLimit-Reset" , reset . toString ( ) ) ;
64
- return response ;
65
- }
95
+ const hash = createHash ( "sha256" ) ;
96
+ hash . update ( authorizationValue ) ;
97
+ const hashedAuthorizationValue = hash . digest ( "hex" ) ;
66
98
67
- async action ( key : string , fn : ( args : ActionFunctionArgs ) => Promise < ReturnType < ActionFunction > > ) {
68
- const { success, pending, limit, reset, remaining } = await this . #rateLimitter. limit (
69
- `ratelimit:${ key } `
99
+ const { success, pending, limit, reset, remaining } = await rateLimiter . limit (
100
+ hashedAuthorizationValue
70
101
) ;
71
102
103
+ res . set ( "x-ratelimit-limit" , limit . toString ( ) ) ;
104
+ res . set ( "x-ratelimit-remaining" , remaining . toString ( ) ) ;
105
+ res . set ( "x-ratelimit-reset" , reset . toString ( ) ) ;
106
+
72
107
if ( success ) {
73
- return fn ;
108
+ if ( log . requests ) {
109
+ logger . info ( `RateLimitter (${ keyPrefix } ): under rate limit` , {
110
+ limit,
111
+ reset,
112
+ remaining,
113
+ hashedAuthorizationValue,
114
+ } ) ;
115
+ }
116
+ return next ( ) ;
74
117
}
75
118
76
- const response = new Response ( "Rate limit exceeded" , { status : 429 } ) ;
77
- response . headers . set ( "X-RateLimit-Limit" , limit . toString ( ) ) ;
78
- response . headers . set ( "X-RateLimit-Remaining" , remaining . toString ( ) ) ;
79
- response . headers . set ( "X-RateLimit-Reset" , reset . toString ( ) ) ;
80
- return response ;
81
- }
119
+ if ( log . rejections ) {
120
+ logger . warn ( `RateLimitter (${ keyPrefix } ): rate limit exceeded` , {
121
+ limit,
122
+ reset,
123
+ remaining,
124
+ pending,
125
+ hashedAuthorizationValue,
126
+ } ) ;
127
+ }
128
+
129
+ res . setHeader ( "Content-Type" , "application/problem+json" ) ;
130
+ return res . status ( 429 ) . send (
131
+ JSON . stringify (
132
+ {
133
+ title : "Rate Limit Exceeded" ,
134
+ status : 429 ,
135
+ type : "https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/429" ,
136
+ detail : `Rate limit exceeded ${ remaining } /${ limit } requests remaining. Retry after ${ reset } seconds.` ,
137
+ reset : reset ,
138
+ limit : limit ,
139
+ } ,
140
+ null ,
141
+ 2
142
+ )
143
+ ) ;
144
+ } ;
82
145
}
83
146
84
- export const standardRateLimitter = new RateLimitter ( {
147
+ type Duration = Parameters < typeof Ratelimit . slidingWindow > [ 1 ] ;
148
+
149
+ export const apiRateLimiter = authorizationRateLimitMiddleware ( {
150
+ keyPrefix : "ratelimit:api" ,
85
151
redis : {
86
152
port : env . REDIS_PORT ,
87
153
host : env . REDIS_HOST ,
@@ -90,5 +156,12 @@ export const standardRateLimitter = new RateLimitter({
90
156
enableAutoPipelining : true ,
91
157
...( env . REDIS_TLS_DISABLED === "true" ? { } : { tls : { } } ) ,
92
158
} ,
93
- limiter : Ratelimit . slidingWindow ( 1 , "60 s" ) ,
159
+ limiter : Ratelimit . slidingWindow ( env . API_RATE_LIMIT_MAX , env . API_RATE_LIMIT_WINDOW as Duration ) ,
160
+ pathMatchers : [ / ^ \/ a p i / ] ,
161
+ log : {
162
+ rejections : true ,
163
+ requests : false ,
164
+ } ,
94
165
} ) ;
166
+
167
+ export type RateLimitMiddleware = ReturnType < typeof authorizationRateLimitMiddleware > ;
0 commit comments