Skip to content

Commit 7f287d1

Browse files
matt-aitkenericallam
authored andcommitted
API rate limiter as Express middleware
1 parent e3c699f commit 7f287d1

File tree

4 files changed

+126
-38
lines changed

4 files changed

+126
-38
lines changed

apps/webapp/app/entry.server.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,3 +198,4 @@ const sqsEventConsumer = singleton("sqsEventConsumer", getSharedSqsEventConsumer
198198
export { wss } from "./v3/handleWebsockets.server";
199199
export { socketIo } from "./v3/handleSocketIo.server";
200200
export { registryProxy } from "./v3/registryProxy.server";
201+
export { apiRateLimiter } from "./services/apiRateLimit.server";

apps/webapp/app/env.server.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,18 @@ const EnvironmentSchema = z.object({
6969
TUNNEL_HOST: z.string().optional(),
7070
TUNNEL_SECRET_KEY: z.string().optional(),
7171

72+
//API Rate limiting
73+
/**
74+
* @example "60s"
75+
* @example "1m"
76+
* @example "1h"
77+
* @example "1d"
78+
* @example "1000ms"
79+
* @example "1000s"
80+
*/
81+
API_RATE_LIMIT_WINDOW: z.string().default("60s"),
82+
API_RATE_LIMIT_MAX: z.coerce.number().int().default(600),
83+
7284
//v3
7385
V3_ENABLED: z.string().default("false"),
7486
OTLP_EXPORTER_TRACES_URL: z.string().optional(),
Lines changed: 109 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
import { ActionFunction, ActionFunctionArgs, LoaderFunction } from "@remix-run/server-runtime";
22
import { Ratelimit } from "@upstash/ratelimit";
3+
import { NextFunction } from "express";
34
import Redis, { RedisOptions } from "ioredis";
45
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";
59

610
function createRedisRateLimitClient(
711
redisOptions: RedisOptions
@@ -29,59 +33,121 @@ function createRedisRateLimitClient(
2933
}
3034

3135
type Options = {
36+
log?: {
37+
requests?: boolean;
38+
rejections?: boolean;
39+
};
3240
redis: RedisOptions;
41+
keyPrefix: string;
42+
pathMatchers: (RegExp | string)[];
3343
limiter: ConstructorParameters<typeof Ratelimit>[0]["limiter"];
3444
};
3545

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+
});
3864

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+
}
4769

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+
}
5082

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+
}
5586

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");
5893
}
5994

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");
6698

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
70101
);
71102

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+
72107
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();
74117
}
75118

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+
};
82145
}
83146

84-
export const standardRateLimitter = new RateLimitter({
147+
type Duration = Parameters<typeof Ratelimit.slidingWindow>[1];
148+
149+
export const apiRateLimiter = authorizationRateLimitMiddleware({
150+
keyPrefix: "ratelimit:api",
85151
redis: {
86152
port: env.REDIS_PORT,
87153
host: env.REDIS_HOST,
@@ -90,5 +156,12 @@ export const standardRateLimitter = new RateLimitter({
90156
enableAutoPipelining: true,
91157
...(env.REDIS_TLS_DISABLED === "true" ? {} : { tls: {} }),
92158
},
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: [/^\/api/],
161+
log: {
162+
rejections: true,
163+
requests: false,
164+
},
94165
});
166+
167+
export type RateLimitMiddleware = ReturnType<typeof authorizationRateLimitMiddleware>;

apps/webapp/server.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { broadcastDevReady, logDevReady } from "@remix-run/server-runtime";
88
import type { Server as IoServer } from "socket.io";
99
import type { Server as EngineServer } from "engine.io";
1010
import { RegistryProxy } from "~/v3/registryProxy.server";
11+
import { RateLimitMiddleware, apiRateLimiter } from "~/services/apiRateLimit.server";
1112

1213
const app = express();
1314

@@ -39,6 +40,7 @@ if (process.env.HTTP_SERVER_DISABLED !== "true") {
3940
const socketIo: { io: IoServer } | undefined = build.entry.module.socketIo;
4041
const wss: WebSocketServer | undefined = build.entry.module.wss;
4142
const registryProxy: RegistryProxy | undefined = build.entry.module.registryProxy;
43+
const apiRateLimiter: RateLimitMiddleware = build.entry.module.apiRateLimiter;
4244

4345
if (registryProxy && process.env.ENABLE_REGISTRY_PROXY === "true") {
4446
console.log(`🐳 Enabling container registry proxy to ${registryProxy.origin}`);
@@ -69,6 +71,8 @@ if (process.env.HTTP_SERVER_DISABLED !== "true") {
6971
});
7072

7173
if (process.env.DASHBOARD_AND_API_DISABLED !== "true") {
74+
app.use(apiRateLimiter);
75+
7276
app.all(
7377
"*",
7478
// @ts-ignore
@@ -84,8 +88,6 @@ if (process.env.HTTP_SERVER_DISABLED !== "true") {
8488
});
8589
}
8690

87-
88-
8991
const server = app.listen(port, () => {
9092
console.log(`✅ server ready: http://localhost:${port} [NODE_ENV: ${MODE}]`);
9193

0 commit comments

Comments
 (0)