Give each session a budget on the data path
Only sign-in and the account endpoints were rate limited. JMAP, blob downloads and the image and calendar proxies had no budget at all, and the proxy is one Node process that saturates a core at roughly 2,000 operations a second -- measured at 110% CPU under 150 concurrent users. One signed-in account looping requests could slow every other user on the instance. Each session now gets API_RATE_LIMIT requests a minute on those routes, 1,200 by default: twenty a second sustained, well above what a busy tab does and an order of magnitude below where one tab starts to hurt the rest. Over budget returns 429 with Retry-After. Sign-in keeps its own, separate limiter. Checked in situ: one session driven flat out was cut off after exactly 1,200 requests, and with API_RATE_LIMIT=0 throughput at 50 users is unchanged.
This commit is contained in:
+17
-4
@@ -63,6 +63,19 @@ const loginFloodLimiter = new RateLimiter(config.loginRateLimit * 20, 15 * 60_00
|
||||
* cannot get the whole deployment banned.
|
||||
*/
|
||||
const accountLimiter = new RateLimiter(10, 15 * 60_000);
|
||||
const apiLimiter = new RateLimiter(config.apiRateLimit, 60_000);
|
||||
|
||||
/** Per-session budget on the data path. See config.apiRateLimit. */
|
||||
const apiRateLimited: MiddlewareHandler<Env> = async (c, next) => {
|
||||
if (config.apiRateLimit > 0) {
|
||||
const session = c.get("session");
|
||||
if (session && !apiLimiter.check(session.id)) {
|
||||
c.header("Retry-After", String(apiLimiter.retryAfterSeconds(session.id)));
|
||||
return c.json({ error: "rate_limited" }, 429);
|
||||
}
|
||||
}
|
||||
await next();
|
||||
};
|
||||
|
||||
const HOP_BY_HOP = new Set([
|
||||
"connection",
|
||||
@@ -583,7 +596,7 @@ export function createApp(basePath = config.basePath): Hono<Env> {
|
||||
});
|
||||
|
||||
// ---------- JMAP API proxy ----------
|
||||
api.post("/jmap", requireSession, async (c) => {
|
||||
api.post("/jmap", requireSession, apiRateLimited, async (c) => {
|
||||
const session = c.get("session");
|
||||
const ct = c.req.header("content-type") ?? "";
|
||||
if (!ct.toLowerCase().startsWith("application/json")) {
|
||||
@@ -644,7 +657,7 @@ export function createApp(basePath = config.basePath): Hono<Env> {
|
||||
});
|
||||
|
||||
// ---------- Blob download ----------
|
||||
api.get("/blob/:accountId/:blobId/:name", requireSession, async (c) => {
|
||||
api.get("/blob/:accountId/:blobId/:name", requireSession, apiRateLimited, async (c) => {
|
||||
const session = c.get("session");
|
||||
const { accountId, blobId, name } = c.req.param();
|
||||
const accept = c.req.query("accept") ?? "application/octet-stream";
|
||||
@@ -724,10 +737,10 @@ export function createApp(basePath = config.basePath): Hono<Env> {
|
||||
});
|
||||
|
||||
// ---------- Remote image privacy proxy ----------
|
||||
api.get("/image", requireSession, imageProxyHandler);
|
||||
api.get("/image", requireSession, apiRateLimited, imageProxyHandler);
|
||||
// Behind the session for the same reason the image proxy is: an open fetcher
|
||||
// on someone else's server is a gift to whoever finds it.
|
||||
api.get("/ics", requireSession, icsProxyHandler);
|
||||
api.get("/ics", requireSession, apiRateLimited, icsProxyHandler);
|
||||
|
||||
api.notFound((c) => c.json({ error: "not_found" }, 404));
|
||||
api.onError((err, c) => {
|
||||
|
||||
Reference in New Issue
Block a user