Merge pull request #289 from Coffey-Labs/footprint

Smaller footprint: three times the tabs, a third of the image, a budget per session
This commit is contained in:
Coffey Labs
2026-09-06 00:46:25 -07:00
committed by GitHub
5 changed files with 154 additions and 8 deletions
+15 -3
View File
@@ -37,16 +37,28 @@ ENV NODE_ENV=production \
IHASMAIL_VERSION=$IHASMAIL_VERSION \ IHASMAIL_VERSION=$IHASMAIL_VERSION \
BASE_PATH=$BASE_PATH BASE_PATH=$BASE_PATH
WORKDIR /app WORKDIR /app
COPY package.json ./ COPY package.json package-lock.json* ./
COPY server/package.json server/ COPY server/package.json server/
# config.ts reads the version through this at startup. With IHASMAIL_VERSION # 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 # 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. # than failing, since there is no git in here to ask.
COPY scripts/ ./scripts/ 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/server/dist ./server/dist
COPY --from=build /app/web/dist ./web/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 USER node
# No `VOLUME ["/data"]`. It reads like documentation for where the session file # 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 # goes, but Docker acts on it: a container started without `-v` gets an
+76 -4
View File
@@ -2,6 +2,9 @@ import { Hono } from "hono";
import type { Context, MiddlewareHandler } from "hono"; import type { Context, MiddlewareHandler } from "hono";
import { getCookie, setCookie, deleteCookie } from "hono/cookie"; import { getCookie, setCookie, deleteCookie } from "hono/cookie";
import { compress } from "hono/compress"; 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 { getConnInfo } from "@hono/node-server/conninfo";
import { config } from "./config.js"; import { config } from "./config.js";
import { SessionStore, type SessionBackend, type LiveSession } from "./sessions.js"; import { SessionStore, type SessionBackend, type LiveSession } from "./sessions.js";
@@ -60,6 +63,19 @@ const loginFloodLimiter = new RateLimiter(config.loginRateLimit * 20, 15 * 60_00
* cannot get the whole deployment banned. * cannot get the whole deployment banned.
*/ */
const accountLimiter = new RateLimiter(10, 15 * 60_000); 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([ const HOP_BY_HOP = new Set([
"connection", "connection",
@@ -580,7 +596,7 @@ export function createApp(basePath = config.basePath): Hono<Env> {
}); });
// ---------- JMAP API proxy ---------- // ---------- JMAP API proxy ----------
api.post("/jmap", requireSession, async (c) => { api.post("/jmap", requireSession, apiRateLimited, async (c) => {
const session = c.get("session"); const session = c.get("session");
const ct = c.req.header("content-type") ?? ""; const ct = c.req.header("content-type") ?? "";
if (!ct.toLowerCase().startsWith("application/json")) { if (!ct.toLowerCase().startsWith("application/json")) {
@@ -641,7 +657,7 @@ export function createApp(basePath = config.basePath): Hono<Env> {
}); });
// ---------- Blob download ---------- // ---------- 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 session = c.get("session");
const { accountId, blobId, name } = c.req.param(); const { accountId, blobId, name } = c.req.param();
const accept = c.req.query("accept") ?? "application/octet-stream"; const accept = c.req.query("accept") ?? "application/octet-stream";
@@ -700,6 +716,7 @@ export function createApp(basePath = config.basePath): Hono<Env> {
try { try {
const upstream = await getUpstreamSession(session.id, session.authorization, upstreamFor(session.username)); const upstream = await getUpstreamSession(session.id, session.authorization, upstreamFor(session.username));
const url = absoluteUpstream(expandTemplate(upstream.eventSourceUrl, { types, closeafter, ping }), upstream.baseUrl); const url = absoluteUpstream(expandTemplate(upstream.eventSourceUrl, { types, closeafter, ping }), upstream.baseUrl);
if (config.rawPushRelay) return relayPushRaw(c, url, session.authorization);
const controller = new AbortController(); const controller = new AbortController();
c.req.raw.signal.addEventListener("abort", () => controller.abort()); c.req.raw.signal.addEventListener("abort", () => controller.abort());
const res = await fetch(url, { const res = await fetch(url, {
@@ -720,10 +737,10 @@ export function createApp(basePath = config.basePath): Hono<Env> {
}); });
// ---------- Remote image privacy proxy ---------- // ---------- 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 // 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. // 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.notFound((c) => c.json({ error: "not_found" }, 404));
api.onError((err, c) => { api.onError((err, c) => {
@@ -790,6 +807,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"]); 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<Env>, 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 { function passthrough(res: Response): Response {
const headers = new Headers(); const headers = new Headers();
res.headers.forEach((v, k) => { res.headers.forEach((v, k) => {
+24
View File
@@ -74,3 +74,27 @@ test("the liveness probe is not compressed, since gzip would make it bigger", as
assert.equal(res.status, 200); assert.equal(res.status, 200);
assert.equal(res.headers.get("content-encoding"), null); 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/");
});
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");
});
+13
View File
@@ -298,6 +298,19 @@ export const config = {
cookieName: env("COOKIE_NAME", "ihm_session"), cookieName: env("COOKIE_NAME", "ihm_session"),
staticDir: process.env.STATIC_DIR ?? fileURLToPath(new URL("../../web/dist", import.meta.url)), staticDir: process.env.STATIC_DIR ?? fileURLToPath(new URL("../../web/dist", import.meta.url)),
loginRateLimit: int("LOGIN_RATE_LIMIT", 10), 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. */
followAdvertisedUrls: process.env.STALWART_FOLLOW_ADVERTISED_URLS === "1",
}; };
export type Config = typeof config; export type Config = typeof config;
+26 -1
View File
@@ -288,9 +288,34 @@ export function localizeSession(s: UpstreamSession, extras: Record<string, unkno
} }
/** Resolve a possibly-relative upstream URL template against STALWART_URL. */ /** Resolve a possibly-relative upstream URL template against STALWART_URL. */
/**
* Resolve a URL Stalwart handed us against the server we were configured to
* talk to.
*
* Stalwart advertises absolute URLs in its session -- apiUrl, eventSourceUrl
* and the rest -- built from its public hostname, which is always https. A
* proxy that follows them takes every upstream call, and every held push
* stream, out through the public route even when STALWART_URL names a private
* plain-HTTP hop on the same network. Measured, that TLS leg is ~80 KiB of
* native OpenSSL state per signed-in tab: 60% of what a tab costs, and the
* whole difference between 1,665 and 3,680 tabs in 256 MiB.
*
* So by default only the path and query are taken from the advertised URL;
* scheme, host and port come from the configured base. That is what a proxy
* should have done all along -- the operator named the route on purpose.
* STALWART_FOLLOW_ADVERTISED_URLS=1 restores the old behaviour for a setup
* that genuinely needs to reach Stalwart at a different origin than the one
* it was given.
*/
export function absoluteUpstream(url: string, base: string = config.stalwartUrl): string { export function absoluteUpstream(url: string, base: string = config.stalwartUrl): string {
try { try {
return new URL(url, base).toString(); const resolved = new URL(url, base);
if (config.followAdvertisedUrls) return resolved.toString();
const pinned = new URL(base);
pinned.pathname = resolved.pathname;
pinned.search = resolved.search;
pinned.hash = "";
return pinned.toString();
} catch { } catch {
return url; return url;
} }