From 01f721d8d1a718c7b1f192b3af3d110f7d84d340 Mon Sep 17 00:00:00 2001 From: John Coffey Date: Sun, 6 Sep 2026 00:42:19 -0700 Subject: [PATCH 1/3] Cut what a signed-in tab costs by two thirds Two changes on the push path, both measured against a real Stalwart 0.16.20 with the container capped at 256 MiB and tabs added in steps of 200 until the kernel killed it: tabs held per tab of which native before 1,665 133 KiB 81 KiB pin upstream calls to STALWART_URL 3,400 58 KiB 8 KiB + raw push relay 4,979 37 KiB 10 KiB Stalwart advertises absolute https URLs in every session, and the proxy followed them -- so even with STALWART_URL naming a private plain-HTTP hop on the same Docker network, every held push stream went out through TLS. That leg is about 80 KiB of OpenSSL state per tab: native memory Node cannot see, which is why neither the heap ceiling nor the stream buffer size ever moved the number. absoluteUpstream() now keeps the path and query from the advertised URL and the scheme, host and port from the configured one. A setup that must reach Stalwart at an origin other than the one it was given sets STALWART_FOLLOW_ADVERTISED_URLS=1. With the transport out of the way, the fetch()-based relay was the next cost: an undici Response, a web ReadableStream, a reader and Hono's stream bridge held alive per tab, about 44 KiB of heap for a session that otherwise costs 4 KiB. relayPushRaw() pipes the upstream socket into the Node response and tells the adapter the response is already sent. RAW_PUSH_RELAY=0 restores the fetch path for comparison. JMAP throughput is unchanged (2,383/s against 2,484/s at 50 users, inside run-to-run noise); the relay does not touch that path. Verified that a push stream through the raw relay delivers a StateChange while mail is written. The install page's advice to set --max-old-space-size was measured in the same runs and made no difference at all -- 3,400 tabs with it and without -- and is withdrawn in the docs alongside this change. --- server/src/app.ts | 59 +++++++++++++++++++++++++++++++++++++ server/src/compress.test.ts | 8 +++++ server/src/config.ts | 4 +++ server/src/upstream.ts | 27 ++++++++++++++++- 4 files changed, 97 insertions(+), 1 deletion(-) diff --git a/server/src/app.ts b/server/src/app.ts index f543a20..327f247 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -2,6 +2,9 @@ import { Hono } from "hono"; import type { Context, MiddlewareHandler } from "hono"; import { getCookie, setCookie, deleteCookie } from "hono/cookie"; import { compress } from "hono/compress"; +import { request as httpRequest } from "node:http"; +import { request as httpsRequest } from "node:https"; +import { RESPONSE_ALREADY_SENT } from "@hono/node-server/utils/response"; import { getConnInfo } from "@hono/node-server/conninfo"; import { config } from "./config.js"; import { SessionStore, type SessionBackend, type LiveSession } from "./sessions.js"; @@ -700,6 +703,7 @@ export function createApp(basePath = config.basePath): Hono { try { const upstream = await getUpstreamSession(session.id, session.authorization, upstreamFor(session.username)); const url = absoluteUpstream(expandTemplate(upstream.eventSourceUrl, { types, closeafter, ping }), upstream.baseUrl); + if (config.rawPushRelay) return relayPushRaw(c, url, session.authorization); const controller = new AbortController(); c.req.raw.signal.addEventListener("abort", () => controller.abort()); const res = await fetch(url, { @@ -790,6 +794,61 @@ function sessionExtras(session: LiveSession, info: AccountInfo = { locale: null, */ const PASSTHROUGH_HEADERS = new Set(["content-type", "content-disposition", "content-language", "etag", "last-modified", "retry-after"]); +/** + * Hold a push stream open with the least machinery that will do it. + * + * The fetch() version above builds an undici Response, a web ReadableStream, + * a reader, and Hono's stream-to-Node bridge for every tab, and keeps all of + * it alive for as long as the tab is open. Measured against a real Stalwart + * that is about 44 KiB of JavaScript heap per tab -- twelve times what the + * session itself costs -- and a signed-in tab is otherwise nothing but this + * one held connection. Here the upstream socket is piped straight into the + * Node response, so what stays resident per tab is two sockets and their + * small IncomingMessage/ServerResponse pair. + * + * Returns a Response Hono treats as already sent: the raw bindings are + * written to directly, and the returned value is never serialised. + */ +function relayPushRaw(c: Context, url: string, authorization: string): Response { + const out = (c.env as { outgoing: import("node:http").ServerResponse }).outgoing; + const target = new URL(url); + const req = (target.protocol === "https:" ? httpsRequest : httpRequest)(target, { + method: "GET", + headers: { authorization, accept: "text/event-stream" }, + }); + const abort = () => req.destroy(); + c.req.raw.signal.addEventListener("abort", abort); + out.on("close", abort); + req.on("response", (res) => { + if (res.statusCode !== 200) { + res.resume(); + out.writeHead(502, { "content-type": "application/json", "cache-control": "no-store" }); + out.end(JSON.stringify({ error: "upstream_error" })); + return; + } + out.writeHead(200, { + "content-type": "text/event-stream", + "cache-control": "no-cache, no-transform", + connection: "keep-alive", + "x-accel-buffering": "no", + }); + out.flushHeaders(); + res.pipe(out); + }); + req.on("error", () => { + if (!out.headersSent) { + out.writeHead(502, { "content-type": "application/json", "cache-control": "no-store" }); + out.end(JSON.stringify({ error: "upstream_error" })); + } else { + out.end(); + } + }); + req.end(); + // Tells @hono/node-server the raw ServerResponse has been written to and + // must be left alone. + return RESPONSE_ALREADY_SENT; +} + function passthrough(res: Response): Response { const headers = new Headers(); res.headers.forEach((v, k) => { diff --git a/server/src/compress.test.ts b/server/src/compress.test.ts index 49da5e6..6e83167 100644 --- a/server/src/compress.test.ts +++ b/server/src/compress.test.ts @@ -74,3 +74,11 @@ test("the liveness probe is not compressed, since gzip would make it bigger", as assert.equal(res.status, 200); assert.equal(res.headers.get("content-encoding"), null); }); + +test("advertised upstream URLs are pinned to the configured origin", async () => { + const { absoluteUpstream } = await import("./upstream.js"); + const pinned = absoluteUpstream("https://mail.public.example/jmap/eventsource/?types=*", "http://stalwart:8080"); + assert.equal(pinned, "http://stalwart:8080/jmap/eventsource/?types=*"); + // A relative URL still resolves against the base, as before. + assert.equal(absoluteUpstream("/jmap/", "http://stalwart:8080/"), "http://stalwart:8080/jmap/"); +}); diff --git a/server/src/config.ts b/server/src/config.ts index b9d34bb..3119f0e 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -298,6 +298,10 @@ 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), + /* 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. */ + followAdvertisedUrls: process.env.STALWART_FOLLOW_ADVERTISED_URLS === "1", }; export type Config = typeof config; diff --git a/server/src/upstream.ts b/server/src/upstream.ts index 06c9361..40eebf8 100644 --- a/server/src/upstream.ts +++ b/server/src/upstream.ts @@ -288,9 +288,34 @@ export function localizeSession(s: UpstreamSession, extras: Record Date: Sun, 6 Sep 2026 00:42:19 -0700 Subject: [PATCH 2/3] Ship the runtime image without the build tree 639 MB unpacked and 119 MB compressed, against 239 MB and 59 MB now. Two causes, both in the runtime stage. The build stage's node_modules was copied across whole: 132 MB of vite, TypeScript, esbuild, jsdom and React that the server never loads, since it needs hono and its Node adapter and nothing else -- about 4 MB. The runtime stage now installs the server workspace's production dependencies on its own. Then `chown -R node:node /data /app` rewrote every one of those files, which on overlayfs copies the whole tree into a second layer of the same size. Only /data is written to at runtime; /app stays root-owned and read-only to the process, which is what an immutable container wants anyway. The base image's npm, npx, yarn and corepack are removed from the runtime stage as well. The server is started with `node` directly and never calls them; anyone who gains code execution should not find a package manager waiting. Checked that the image starts --read-only, serves the gzipped bundle, signs in against Stalwart, holds a push stream, and that `hono` loads from the 3.1 MB that remains. --- Dockerfile | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/Dockerfile b/Dockerfile index e0c8fab..323f41b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -37,16 +37,28 @@ ENV NODE_ENV=production \ IHASMAIL_VERSION=$IHASMAIL_VERSION \ BASE_PATH=$BASE_PATH WORKDIR /app -COPY package.json ./ +COPY package.json package-lock.json* ./ COPY server/package.json server/ # config.ts reads the version through this at startup. With IHASMAIL_VERSION # set it never looks further; without it, it falls back to package.json rather # than failing, since there is no git in here to ask. COPY scripts/ ./scripts/ -COPY --from=build /app/node_modules ./node_modules +# Only what the server loads at runtime: hono and its Node adapter, about 4 MB. +# The build stage's tree is 132 MB of vite, TypeScript, esbuild and React that +# never executes here but shipped anyway -- and showed up in every CVE scan. +RUN npm ci --ignore-scripts --omit=dev --workspace server \ + && rm -rf /root/.npm /tmp/* COPY --from=build /app/server/dist ./server/dist COPY --from=build /app/web/dist ./web/dist -RUN mkdir -p /data && chown -R node:node /data /app +# /data is the only path the process may write. /app stays root-owned and +# read-only to the runtime user on purpose; the previous `chown -R /app` +# re-wrote every file and, on overlayfs, duplicated the whole tree into a +# second 173 MB layer. +RUN mkdir -p /data && chown node:node /data \ + # The base image ships a package manager the server never calls. Anyone who + # gets code execution should not find one waiting for them. + && rm -rf /usr/local/lib/node_modules /usr/local/bin/npm /usr/local/bin/npx \ + /usr/local/bin/corepack /opt/yarn* /usr/local/bin/yarn /usr/local/bin/yarnpkg USER node # No `VOLUME ["/data"]`. It reads like documentation for where the session file # goes, but Docker acts on it: a container started without `-v` gets an From ed93fefb9b31537452bec1e6e25dd406dd8ffb9b Mon Sep 17 00:00:00 2001 From: John Coffey Date: Sun, 6 Sep 2026 00:42:49 -0700 Subject: [PATCH 3/3] 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. --- server/src/app.ts | 21 +++++++++++++++++---- server/src/compress.test.ts | 16 ++++++++++++++++ server/src/config.ts | 9 +++++++++ 3 files changed, 42 insertions(+), 4 deletions(-) diff --git a/server/src/app.ts b/server/src/app.ts index 327f247..49cc0ad 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -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 = 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 { }); // ---------- 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 { }); // ---------- 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 { }); // ---------- 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) => { diff --git a/server/src/compress.test.ts b/server/src/compress.test.ts index 6e83167..aa8e95e 100644 --- a/server/src/compress.test.ts +++ b/server/src/compress.test.ts @@ -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"); +}); diff --git a/server/src/config.ts b/server/src/config.ts index 3119f0e..ee07c6e 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -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. */