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 diff --git a/server/src/app.ts b/server/src/app.ts index f543a20..49cc0ad 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"; @@ -60,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", @@ -580,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")) { @@ -641,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"; @@ -700,6 +716,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, { @@ -720,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) => { @@ -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"]); +/** + * 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..aa8e95e 100644 --- a/server/src/compress.test.ts +++ b/server/src/compress.test.ts @@ -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.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"); +}); diff --git a/server/src/config.ts b/server/src/config.ts index b9d34bb..ee07c6e 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -298,6 +298,19 @@ 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. */ + 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