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:
2026-09-06 00:42:49 -07:00
parent 6098ffb8e5
commit ed93fefb9b
3 changed files with 42 additions and 4 deletions
+17 -4
View File
@@ -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) => {
+16
View File
@@ -82,3 +82,19 @@ test("advertised upstream URLs are pinned to the configured origin", async () =>
// A relative URL still resolves against the base, as before.
assert.equal(absoluteUpstream("/jmap/", "http://stalwart:8080/"), "http://stalwart:8080/jmap/");
});
test("the data path is rate limited per session, and login stays on its own budget", async () => {
// No session: every call is refused before the limiter, so it must never 429.
const app = createApp();
for (let i = 0; i < 5; i++) {
const res = await app.request("/api/jmap", { method: "POST",
headers: { "content-type": "application/json", "x-requested-with": "ihasmail" }, body: "{}" });
assert.equal(res.status, 401);
}
// The limiter itself: a fresh key gets its budget and nothing more.
const { RateLimiter } = await import("./ratelimit.js");
const l = new RateLimiter(3, 60_000);
assert.deepEqual([l.check("s1"), l.check("s1"), l.check("s1"), l.check("s1")], [true, true, true, false]);
assert.ok(l.retryAfterSeconds("s1") >= 1);
assert.equal(l.check("s2"), true, "another session is not affected");
});
+9
View File
@@ -298,6 +298,15 @@ export const config = {
cookieName: env("COOKIE_NAME", "ihm_session"),
staticDir: process.env.STATIC_DIR ?? fileURLToPath(new URL("../../web/dist", import.meta.url)),
loginRateLimit: int("LOGIN_RATE_LIMIT", 10),
/*
* Requests per minute one session may make on the data path -- JMAP, blobs,
* the image and calendar proxies. The proxy is one Node process and saturates
* a core at roughly 2,000 operations a second, so without this a single
* signed-in user can deny service to everyone else. 1,200 a minute is 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. 0 disables it.
*/
apiRateLimit: int("API_RATE_LIMIT", 1200),
/* See relayPushRaw(): pipe the push stream socket-to-socket instead of through fetch(). */
rawPushRelay: process.env.RAW_PUSH_RELAY !== "0",
/* See absoluteUpstream(): follow Stalwart's advertised origin instead of pinning to ours. */