diff --git a/.env.example b/.env.example index 9377751..f25d19f 100644 --- a/.env.example +++ b/.env.example @@ -61,6 +61,12 @@ MAX_UPLOAD_BYTES=52428800 # Remote-image privacy proxy (Gmail-style). Set to 0 to load remote images directly. IMAGE_PROXY=1 +# In-app administration, for accounts whose Stalwart role manages accounts and +# domains. 0 turns it off for everyone: no menu, and the JMAP proxy refuses +# Stalwart's registry methods beyond an account's own password, app passwords +# and settings. Stalwart's own admin interface is not affected. +ADMINISTRATION=1 + # Branding APP_NAME=ihasmail diff --git a/FEATURES.md b/FEATURES.md index cf828cc..bea5251 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -1140,6 +1140,16 @@ in as it. ihasmail shows any account that outranks the viewer read-only, and counts a role it cannot read as outranking rather than not. Nobody can change their own role or delete the account they are signed in with. +## An operator can turn it off + +`ADMINISTRATION=0` at launch removes it for everyone, and not only from the +menu. The permissions are no longer sent to the browser, and the JMAP proxy +refuses Stalwart registry methods except the ones about the signed-in account +itself — its password, app passwords, API keys, public keys, masked addresses +and account settings. Without that, hiding the menu would leave an +administrator's browser console able to make every call the menu made. +Stalwart's own interface is unaffected; this decides what ihasmail offers. + ## Stateless, as everything else Nothing new is stored anywhere. There is no admin route on ihasmail's server, @@ -1521,6 +1531,7 @@ wizard, because either would be state. | `UPSTREAM_TIMEOUT` | `30000` | Milliseconds | | `MAX_UPLOAD_BYTES` | `52428800` | 50 MB | | `IMAGE_PROXY` | `1` | Privacy proxy for remote images | +| `ADMINISTRATION` | `1` | Offer in-app administration to accounts whose Stalwart role allows it; `0` turns it off, in the proxy as well as the menu | | `LOGIN_RATE_LIMIT` | `10` | Attempts per window | | `COOKIE_NAME` | `ihm_session` | | | `APP_NAME` | `ihasmail` | Branding | diff --git a/server/src/adminGate.test.ts b/server/src/adminGate.test.ts new file mode 100644 index 0000000..b71fcaf --- /dev/null +++ b/server/src/adminGate.test.ts @@ -0,0 +1,40 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { gateAdministration } from "./adminGate.js"; + +const req = (...methods: string[]) => JSON.stringify({ using: ["urn:ietf:params:jmap:core"], methodCalls: methods.map((m, i) => [m, {}, `c${i}`]) }); + +/** + * With ADMINISTRATION=0 an administrator's browser must not be a way round the + * operator's decision. Hiding the menu would leave the proxy forwarding the + * very calls the menu made. + */ +test("mail, calendars and the rest pass untouched", () => { + const r = gateAdministration(req("Email/query", "Mailbox/get", "CalendarEvent/set", "FileNode/get", "Principal/getAvailability")); + assert.equal(r.ok, true); +}); + +test("the account's own registry objects pass", () => { + assert.equal(gateAdministration(req("x:AccountSettings/get", "x:AppPassword/set", "x:PublicKey/get", "x:MaskedEmail/set")).ok, true); +}); + +test("directory and server objects are refused, and named", () => { + for (const m of ["x:Account/get", "x:Domain/set", "x:Role/query", "x:Tenant/get", "x:SystemSettings/set", "x:DkimSignature/get"]) { + assert.deepEqual(gateAdministration(req("Email/get", m)), { ok: false, method: m }); + } +}); + +test("a body that cannot be read is refused rather than forwarded unchecked", () => { + assert.deepEqual(gateAdministration("{not json"), { ok: false, method: null }); + assert.deepEqual(gateAdministration(JSON.stringify({ methodCalls: "x:Account/get" })), { ok: false, method: null }); + assert.deepEqual(gateAdministration(JSON.stringify({ methodCalls: [[{}, {}, "c"]] })), { ok: false, method: null }); +}); + +test("what is forwarded is what was checked", () => { + // A duplicate key is read one way by JSON.parse; forwarding the parsed form + // means the server cannot read it the other way. + const raw = '{"methodCalls":[["x:Account/get",{},"a"]],"methodCalls":[["Email/get",{},"b"]]}'; + const r = gateAdministration(raw); + assert.equal(r.ok, true); + if (r.ok) assert.equal(r.body, JSON.stringify({ methodCalls: [["Email/get", {}, "b"]] })); +}); diff --git a/server/src/adminGate.ts b/server/src/adminGate.ts new file mode 100644 index 0000000..e09503e --- /dev/null +++ b/server/src/adminGate.ts @@ -0,0 +1,47 @@ +/** + * What the JMAP proxy lets through when an operator has turned in-app + * administration off (`ADMINISTRATION=0`). + * + * Hiding the menu is not turning it off. `/api/jmap` forwards any method the + * browser sends, and Stalwart's registry answers whatever the credential's role + * allows -- so without this, an administrator could still manage accounts, or + * the whole server, from the browser console of an installation whose operator + * said no. With it off, the proxy refuses every `x:` method except the few that + * are about the signed-in account itself. + * + * An allowlist rather than a list of administrative objects, because the + * registry has dozens of them -- listeners, stores, tracers, system settings -- + * and a new release adds more. An object not named here is refused, which errs + * towards the operator's decision. + * + * The standard JMAP methods (mail, calendars, contacts, files, sharing) are not + * touched: they act on what the account can already reach. + */ +const SELF_SERVICE = new Set(["AccountSettings", "AccountPassword", "AppPassword", "ApiKey", "PublicKey", "MaskedEmail"]); + +export type GateResult = { ok: true; body: string } | { ok: false; method: string | null }; + +/** + * Check a JMAP request body. On success, hands back the body to forward -- + * serialised from what was inspected, so the server can never be sent + * something different from what was checked (a duplicate key, say, read one + * way here and another way there). + */ +export function gateAdministration(raw: string): GateResult { + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return { ok: false, method: null }; + } + const calls = (parsed as { methodCalls?: unknown } | null)?.methodCalls; + if (!Array.isArray(calls)) return { ok: false, method: null }; + for (const call of calls) { + const name = Array.isArray(call) ? call[0] : undefined; + if (typeof name !== "string") return { ok: false, method: null }; + if (!name.startsWith("x:")) continue; + const object = name.slice(2).split("/")[0] ?? ""; + if (!SELF_SERVICE.has(object)) return { ok: false, method: name }; + } + return { ok: true, body: JSON.stringify(parsed) }; +} diff --git a/server/src/app.ts b/server/src/app.ts index 28bc74b..2d25583 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -8,6 +8,7 @@ import { RESPONSE_ALREADY_SENT } from "@hono/node-server/utils/response"; import { attach as pushAttach, attachRelay as pushAttachRelay, prepare as pushPrepare, receive as pushReceive, pushStatus } from "./push.js"; import { getConnInfo } from "@hono/node-server/conninfo"; import { config } from "./config.js"; +import { gateAdministration } from "./adminGate.js"; import { SessionStore, type SessionBackend, type LiveSession } from "./sessions.js"; import { RateLimiter } from "./ratelimit.js"; import { resolveClientIp } from "./clientip.js"; @@ -633,6 +634,28 @@ export function createApp(basePath = config.basePath): Hono { if (!ct.toLowerCase().startsWith("application/json")) { return c.json({ error: "unsupported_media_type" }, 415); } + /* + * With administration switched off the body is read and checked before it + * goes anywhere; with it on, it streams straight through as it always has, + * so an installation that allows administration pays nothing for this. + */ + let body: ReadableStream | string | null = c.req.raw.body; + if (!config.administration) { + let raw: string; + try { + // Counted as it arrives: a chunked body carries no length to refuse up front. + raw = c.req.raw.body ? await new Response(c.req.raw.body.pipeThrough(byteCap(MAX_GATED_REQUEST))).text() : ""; + } catch { + return c.json({ error: "too_large" }, 413); + } + const gate = gateAdministration(raw); + if (!gate.ok) { + return gate.method + ? c.json({ error: "administration_disabled", message: `Administration is turned off on this installation (${gate.method}).` }, 403) + : c.json({ error: "bad_request", message: "Not a JMAP request." }, 400); + } + body = gate.body; + } try { const upstream = await getUpstreamSession(session.id, session.authorization, upstreamFor(session.username)); const res = await fetch(absoluteUpstream(upstream.apiUrl, upstream.baseUrl), { @@ -642,7 +665,7 @@ export function createApp(basePath = config.basePath): Hono { "content-type": "application/json", accept: "application/json", }, - body: c.req.raw.body, + body, duplex: "half", signal: AbortSignal.timeout(config.upstreamTimeout), }); @@ -838,11 +861,14 @@ function sessionExtras(session: LiveSession, info: AccountInfo = { locale: null, userLocale: info.locale, /** What the upstream server would tell us about itself. */ server: { edition: info.edition }, + /** Whether this installation offers administration at all (ADMINISTRATION). */ + administration: config.administration, /** * The account's permissions on that server, so the client can offer * administration to those who have it. Stalwart still decides every call. + * Withheld when administration is off: nothing in the browser needs them. */ - permissions: info.permissions, + permissions: config.administration ? info.permissions : [], }, }; } @@ -852,6 +878,12 @@ function sessionExtras(session: LiveSession, info: AccountInfo = { locale: null, * denylist: everything else it might set — cookies, auth challenges, CORS * grants — would be landing on *our* origin, where it means something else. */ +/** + * The largest JMAP request read into memory for the administration check. + * Stalwart's own default `maxSizeRequest` is 10 MB; uploads never come this way. + */ +const MAX_GATED_REQUEST = 16 * 1024 * 1024; + const PASSTHROUGH_HEADERS = new Set(["content-type", "content-disposition", "content-language", "etag", "last-modified", "retry-after"]); /** diff --git a/server/src/config.ts b/server/src/config.ts index 14b80e1..a3db72e 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -295,6 +295,13 @@ export const config = { upstreamTimeout: int("UPSTREAM_TIMEOUT", 30_000), maxUploadBytes: int("MAX_UPLOAD_BYTES", 50 * 1024 * 1024), imageProxy: bool("IMAGE_PROXY", true), + /* + * Whether ihasmail offers administration to accounts whose Stalwart role + * allows it. Off means off: no menu, no permissions sent to the browser, and + * the JMAP proxy refuses registry methods beyond the account's own -- see + * adminGate.ts. Stalwart's own interface is unaffected either way. + */ + administration: bool("ADMINISTRATION", true), 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), diff --git a/web/src/jmap/types.ts b/web/src/jmap/types.ts index 12af550..0fe74a0 100644 --- a/web/src/jmap/types.ts +++ b/web/src/jmap/types.ts @@ -39,6 +39,8 @@ export interface JmapSession { /** "oss" | "community" | "enterprise". Stalwart publishes no version. */ edition?: string | null; }; + /** False when the operator has turned in-app administration off. */ + administration?: boolean; /** * The account's effective permissions on that server, as Stalwart reports * them. What the client offers is shaped by these; what is allowed is diff --git a/web/src/views/admin/usePermissions.ts b/web/src/views/admin/usePermissions.ts index 16d9f50..17f96e2 100644 --- a/web/src/views/admin/usePermissions.ts +++ b/web/src/views/admin/usePermissions.ts @@ -11,6 +11,7 @@ import { permissionSet, type Permissions } from "@/lib/adminAccess"; * everything that depends on it, whose requests could bring another refresh. */ export function usePermissions(): Permissions { - const key = useSession((s) => (s.session?.ihasmail?.permissions ?? []).join(",")); + // An installation with administration off sends none; this is belt and braces. + const key = useSession((s) => (s.session?.ihasmail?.administration === false ? "" : (s.session?.ihasmail?.permissions ?? []).join(","))); return useMemo(() => permissionSet(key ? key.split(",") : []), [key]); }