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:
+15
-3
@@ -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
|
||||
|
||||
+76
-4
@@ -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<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",
|
||||
@@ -580,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")) {
|
||||
@@ -641,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";
|
||||
@@ -700,6 +716,7 @@ export function createApp(basePath = config.basePath): Hono<Env> {
|
||||
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<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) => {
|
||||
@@ -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<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 {
|
||||
const headers = new Headers();
|
||||
res.headers.forEach((v, k) => {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
+26
-1
@@ -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 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 {
|
||||
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 {
|
||||
return url;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user