From f1638b2fee40d9a4dc9cb6151103347e22f2fcac Mon Sep 17 00:00:00 2001 From: John Coffey Date: Sun, 13 Sep 2026 16:34:20 -0700 Subject: [PATCH] Offer administration only on a device marked as your own A session signed in without "This is my own device" can no longer administer. The server withholds the account's permissions from it and the JMAP proxy refuses registry methods beyond the account's own, the same gate ADMINISTRATION=0 uses. A borrowed or shared machine is where nobody should be able to reset a password or remove a domain. An administrator in such a session still sees Administration in the account menu, greyed out, with the reason and the fix: sign in again with the box ticked. The server tells that session only that the account administers. The gate now reads the body only when it could name a registry method -- "x: in the text, or a \u escape that could spell one -- so ordinary mail traffic from an untrusted session is forwarded untouched. 1 new string, translated in all nine catalogues, quoting each language's own label for the tickbox; strings falling back to English stay at 16. --- FEATURES.md | 15 +++++++++++++ server/src/adminGate.test.ts | 39 +++++++++++++++++++++++++++++---- server/src/adminGate.ts | 42 ++++++++++++++++++++++++++++++++++-- server/src/app.ts | 36 ++++++++++++++++++++----------- web/src/jmap/types.ts | 7 +++++- web/src/locales/de.ts | 1 + web/src/locales/es.ts | 1 + web/src/locales/fr.ts | 1 + web/src/locales/ja.ts | 1 + web/src/locales/nl.ts | 1 + web/src/locales/pt-BR.ts | 1 + web/src/locales/ru.ts | 1 + web/src/locales/uk.ts | 1 + web/src/locales/zh-Hans.ts | 1 + web/src/views/AppShell.tsx | 16 ++++++++++++++ 15 files changed, 145 insertions(+), 19 deletions(-) diff --git a/FEATURES.md b/FEATURES.md index d534e41..afcce59 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -1166,6 +1166,20 @@ For a role that can read domains (`sysDomainQuery`, `sysDomainGet`): Switching DNS, DKIM or certificate management between automatic and manual, and choosing a DNS or ACME provider, stay in Stalwart's own interface for now. +## Only on your own device + +Administration is available only to a session signed in with **"This is my own +device"** ticked. A borrowed laptop or a shared machine is exactly where nobody +should be able to reset a password or remove a domain, and that tickbox is the +one question the sign-in page already asks about where it is being used. + +It is enforced the same way as the switch below: an untrusted session is sent +no permissions, and the JMAP proxy refuses registry methods beyond the account's +own. The menu still shows **Administration** to an administrator in that +session, greyed out, with the reason and what to do about it — signing in again +with the box ticked — rather than losing the entry without a word. All the +server tells that session is that the account administers, never what it may do. + ## An operator can turn it off `ADMINISTRATION=0` at launch removes it for everyone, and not only from the @@ -1373,6 +1387,7 @@ costs something to get wrong is the one that assumes the machine is yours. | Idle sign-out | after 5 minutes | none | | Kept on the computer | nothing | settings cache, recent addresses, username | | Background notifications | refused | available | +| Administration | unavailable | available, if the role allows it | Local storage is gated on that answer for **reads** as well as writes — a machine trusted once still has residue, and honouring it would let a previous diff --git a/server/src/adminGate.test.ts b/server/src/adminGate.test.ts index b71fcaf..89e9ee7 100644 --- a/server/src/adminGate.test.ts +++ b/server/src/adminGate.test.ts @@ -1,6 +1,6 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import { gateAdministration } from "./adminGate.js"; +import { administrationAllowed, gateAdministration, grantsAdministration, mayNameRegistryMethod } from "./adminGate.js"; const req = (...methods: string[]) => JSON.stringify({ using: ["urn:ietf:params:jmap:core"], methodCalls: methods.map((m, i) => [m, {}, `c${i}`]) }); @@ -24,10 +24,41 @@ test("directory and server objects are refused, and named", () => { } }); -test("a body that cannot be read is refused rather than forwarded unchecked", () => { - assert.deepEqual(gateAdministration("{not json"), { ok: false, method: null }); +test("a body that could name a registry method and cannot be read is refused rather than forwarded", () => { + assert.deepEqual(gateAdministration('{"methodCalls": [["x:Account/get"'), { 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 }); + assert.deepEqual(gateAdministration(JSON.stringify({ methodCalls: [[{}, {}, "c"]], note: "x:" })), { ok: false, method: null }); +}); + +test("a body that cannot name a registry method is forwarded exactly as it came", () => { + // Most traffic from a session that may not administer: no parse, no rewrite. + const raw = '{"using":["urn:ietf:params:jmap:core"],"methodCalls":[["Email/get",{"ids":["a"]},"c"]]}'; + assert.equal(mayNameRegistryMethod(raw), false); + assert.deepEqual(gateAdministration(raw), { ok: true, body: raw }); +}); + +test("a method name hidden behind a unicode escape is still found", () => { + // JSON.parse and the server both read \u0078 as "x"; a substring check alone would not. + const raw = '{"methodCalls":[["\\u0078:Account/get",{},"c"]]}'; + assert.equal(mayNameRegistryMethod(raw), true); + assert.deepEqual(gateAdministration(raw), { ok: false, method: "x:Account/get" }); +}); + +/** + * The operator's rule: administration only from a session signed in with + * "This is my own device" ticked, and never when the installation turned it off. + */ +test("administration needs both the installation and a device marked as the person's own", () => { + assert.equal(administrationAllowed(true, true), true); + assert.equal(administrationAllowed(true, false), false); + assert.equal(administrationAllowed(false, true), false); +}); + +test("an account counts as an administrator by the same test the menu makes", () => { + assert.equal(grantsAdministration(["sysAccountQuery", "sysAccountGet"]), true); + assert.equal(grantsAdministration(["sysDomainQuery", "sysDomainGet"]), true); + assert.equal(grantsAdministration(["sysAccountQuery", "sysDomainGet"]), false); + assert.equal(grantsAdministration(["jmapEmailGet", "sysAccountSettingsGet"]), false); }); test("what is forwarded is what was checked", () => { diff --git a/server/src/adminGate.ts b/server/src/adminGate.ts index e09503e..46f28c9 100644 --- a/server/src/adminGate.ts +++ b/server/src/adminGate.ts @@ -1,6 +1,7 @@ /** - * What the JMAP proxy lets through when an operator has turned in-app - * administration off (`ADMINISTRATION=0`). + * What the JMAP proxy lets through for a session that may not administer: + * the operator turned it off (`ADMINISTRATION=0`), or the session was signed + * in without "This is my own device". * * 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 @@ -21,6 +22,42 @@ const SELF_SERVICE = new Set(["AccountSettings", "AccountPassword", "AppPassword export type GateResult = { ok: true; body: string } | { ok: false; method: string | null }; +/** + * Whether a session may administer at all: the installation allows it, and + * the person signing in said the device is their own. + * + * The second half is the operator's rule, not Stalwart's. A borrowed laptop or + * a library machine is exactly where a session should not be able to reset a + * password or remove a domain, and "This is my own device" is the one thing + * the sign-in form already asks that says where it is being used. An untrusted + * session is also signed out when idle and wipes its local data, so nothing + * about it suits an administrator's work. + */ +export function administrationAllowed(enabled: boolean, remember: boolean): boolean { + return enabled && remember; +} + +/** + * Whether an account's permissions would put Administration in its menu -- + * the same test the client makes, so the server can say why it is missing + * without handing over the permissions themselves. + */ +export function grantsAdministration(permissions: readonly string[]): boolean { + const has = new Set(permissions); + return (has.has("sysAccountQuery") && has.has("sysAccountGet")) || (has.has("sysDomainQuery") && has.has("sysDomainGet")); +} + +/** + * Whether a body could hold a registry method name at all, so the common case + * -- mail, calendars, contacts from a session that may not administer -- skips + * the parse. A method name is a JSON string starting `x:`, which appears in the + * text as `"x:` unless written with a `\u` escape; a body with neither cannot + * contain one, and is forwarded exactly as it came. + */ +export function mayNameRegistryMethod(raw: string): boolean { + return raw.includes('"x:') || raw.includes("\\u"); +} + /** * 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 @@ -28,6 +65,7 @@ export type GateResult = { ok: true; body: string } | { ok: false; method: strin * way here and another way there). */ export function gateAdministration(raw: string): GateResult { + if (!mayNameRegistryMethod(raw)) return { ok: true, body: raw }; let parsed: unknown; try { parsed = JSON.parse(raw); diff --git a/server/src/app.ts b/server/src/app.ts index 520f513..f7245c1 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -8,7 +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 { administrationAllowed, gateAdministration, grantsAdministration } from "./adminGate.js"; import { SessionStore, type SessionBackend, type LiveSession } from "./sessions.js"; import { RateLimiter } from "./ratelimit.js"; import { resolveClientIp } from "./clientip.js"; @@ -639,12 +639,13 @@ export function createApp(basePath = config.basePath): Hono { 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. + * For a session that may not administer -- administration switched off, or + * a device not marked as the person's own -- the body is read and checked + * before it goes anywhere. A session that may streams straight through as + * it always has, and pays nothing for this. */ let body: ReadableStream | string | null = c.req.raw.body; - if (!config.administration) { + if (!administrationAllowed(config.administration, session.remember)) { let raw: string; try { // Counted as it arrives: a chunked body carries no length to refuse up front. @@ -654,9 +655,10 @@ export function createApp(basePath = config.basePath): Hono { } 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); + if (!gate.method) return c.json({ error: "bad_request", message: "Not a JMAP request." }, 400); + return config.administration + ? c.json({ error: "administration_needs_own_device", message: `Administration is only available when signed in on a device marked as your own (${gate.method}).` }, 403) + : c.json({ error: "administration_disabled", message: `Administration is turned off on this installation (${gate.method}).` }, 403); } body = gate.body; } @@ -865,14 +867,24 @@ 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, + /** + * Whether this session may administer: the installation offers it + * (ADMINISTRATION) and the person signed in on a device marked as their own. + */ + administration: administrationAllowed(config.administration, session.remember), + /** + * An administrator signed in on a device not marked as their own, so the + * menu can say why Administration is unavailable rather than lose it + * without a word. Says only that the account administers, never what it + * may do. + */ + administrationNeedsOwnDevice: config.administration && !session.remember && grantsAdministration(info.permissions), /** * 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. + * Withheld from a session that may not administer: nothing in it needs them. */ - permissions: config.administration ? info.permissions : [], + permissions: administrationAllowed(config.administration, session.remember) ? info.permissions : [], }, }; } diff --git a/web/src/jmap/types.ts b/web/src/jmap/types.ts index 0fe74a0..5d9391f 100644 --- a/web/src/jmap/types.ts +++ b/web/src/jmap/types.ts @@ -39,8 +39,13 @@ export interface JmapSession { /** "oss" | "community" | "enterprise". Stalwart publishes no version. */ edition?: string | null; }; - /** False when the operator has turned in-app administration off. */ + /** + * False when this session may not administer: the operator turned it off, + * or the session was signed in without "This is my own device". + */ administration?: boolean; + /** An administrator on a device not marked as their own; the menu says so. */ + administrationNeedsOwnDevice?: 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/locales/de.ts b/web/src/locales/de.ts index 66ab207..db84a23 100644 --- a/web/src/locales/de.ts +++ b/web/src/locales/de.ts @@ -113,6 +113,7 @@ export const catalog: Catalog = { "also {names}": "auch {names}", "The server did not say whether the domain was created.": "Der Server hat nicht mitgeteilt, ob die Domain angelegt wurde.", // ── Administration: accounts ─────────────────────────────────── + "Only on a device you've marked as your own. Sign in again with \u201cThis is my own device\u201d ticked.": "Nur auf einem Gerät, das Sie als Ihr eigenes markiert haben. Melden Sie sich erneut an und setzen Sie das Häkchen bei „Das ist mein eigenes Gerät“.", "Change your own password in {settings}.": "Ihr eigenes Passwort ändern Sie unter {settings}.", "Administration": "Verwaltung", "Directory": "Verzeichnis", diff --git a/web/src/locales/es.ts b/web/src/locales/es.ts index b9ca977..7ce6c35 100644 --- a/web/src/locales/es.ts +++ b/web/src/locales/es.ts @@ -105,6 +105,7 @@ export const catalog: Catalog = { "also {names}": "también {names}", "The server did not say whether the domain was created.": "El servidor no indicó si el dominio se creó.", // ── Administration: accounts ─────────────────────────────────── + "Only on a device you've marked as your own. Sign in again with \u201cThis is my own device\u201d ticked.": "Solo en un dispositivo que haya marcado como suyo. Vuelva a iniciar sesión con «Este es mi propio dispositivo» marcado.", "Change your own password in {settings}.": "Cambie su propia contraseña en {settings}.", "Administration": "Administración", "Directory": "Directorio", diff --git a/web/src/locales/fr.ts b/web/src/locales/fr.ts index c55149d..5ea16d8 100644 --- a/web/src/locales/fr.ts +++ b/web/src/locales/fr.ts @@ -110,6 +110,7 @@ export const catalog: Catalog = { "also {names}": "aussi {names}", "The server did not say whether the domain was created.": "Le serveur n’a pas indiqué si le domaine a été créé.", // ── Administration: accounts ─────────────────────────────────── + "Only on a device you've marked as your own. Sign in again with \u201cThis is my own device\u201d ticked.": "Uniquement sur un appareil que vous avez indiqué comme le vôtre. Reconnectez-vous en cochant « Cet appareil est le mien ».", "Change your own password in {settings}.": "Modifiez votre propre mot de passe dans {settings}.", "Administration": "Administration", "Directory": "Annuaire", diff --git a/web/src/locales/ja.ts b/web/src/locales/ja.ts index 069efe6..15ac4e7 100644 --- a/web/src/locales/ja.ts +++ b/web/src/locales/ja.ts @@ -104,6 +104,7 @@ export const catalog: Catalog = { "also {names}": "別名: {names}", "The server did not say whether the domain was created.": "ドメインが作成されたかどうか、サーバーから応答がありませんでした。", // ── Administration: accounts ─────────────────────────────────── + "Only on a device you've marked as your own. Sign in again with \u201cThis is my own device\u201d ticked.": "自分のデバイスとして指定した端末でのみ使えます。「これは自分のデバイスです」にチェックを入れて、もう一度サインインしてください。", "Change your own password in {settings}.": "ご自身のパスワードは{settings}で変更してください。", "Administration": "管理", "Directory": "ディレクトリ", diff --git a/web/src/locales/nl.ts b/web/src/locales/nl.ts index 93c039a..10fefbb 100644 --- a/web/src/locales/nl.ts +++ b/web/src/locales/nl.ts @@ -101,6 +101,7 @@ export const catalog: Catalog = { "also {names}": "ook {names}", "The server did not say whether the domain was created.": "De server heeft niet gemeld of het domein is aangemaakt.", // ── Administration: accounts ─────────────────────────────────── + "Only on a device you've marked as your own. Sign in again with \u201cThis is my own device\u201d ticked.": "Alleen op een apparaat dat u als uw eigen apparaat hebt aangemerkt. Log opnieuw in met ‘Dit is mijn eigen apparaat’ aangevinkt.", "Change your own password in {settings}.": "Wijzig uw eigen wachtwoord bij {settings}.", "Administration": "Beheer", "Directory": "Adreslijst", diff --git a/web/src/locales/pt-BR.ts b/web/src/locales/pt-BR.ts index e722d88..a7cfa4f 100644 --- a/web/src/locales/pt-BR.ts +++ b/web/src/locales/pt-BR.ts @@ -108,6 +108,7 @@ export const catalog: Catalog = { "also {names}": "também {names}", "The server did not say whether the domain was created.": "O servidor não informou se o domínio foi criado.", // ── Administration: accounts ─────────────────────────────────── + "Only on a device you've marked as your own. Sign in again with \u201cThis is my own device\u201d ticked.": "Só em um dispositivo que você marcou como seu. Entre novamente com “Este dispositivo é meu” marcado.", "Change your own password in {settings}.": "Altere sua própria senha em {settings}.", "Administration": "Administração", "Directory": "Diretório", diff --git a/web/src/locales/ru.ts b/web/src/locales/ru.ts index cac7695..66d186d 100644 --- a/web/src/locales/ru.ts +++ b/web/src/locales/ru.ts @@ -107,6 +107,7 @@ export const catalog: Catalog = { "also {names}": "также {names}", "The server did not say whether the domain was created.": "Сервер не сообщил, создан ли домен.", // ── Administration: accounts ─────────────────────────────────── + "Only on a device you've marked as your own. Sign in again with \u201cThis is my own device\u201d ticked.": "Только на устройстве, отмеченном как ваше. Войдите снова, отметив «Это моё личное устройство».", "Change your own password in {settings}.": "Свой пароль можно изменить в разделе {settings}.", "Administration": "Администрирование", "Directory": "Каталог", diff --git a/web/src/locales/uk.ts b/web/src/locales/uk.ts index 1fde964..a0b9822 100644 --- a/web/src/locales/uk.ts +++ b/web/src/locales/uk.ts @@ -101,6 +101,7 @@ export const catalog: Catalog = { "also {names}": "також {names}", "The server did not say whether the domain was created.": "Сервер не повідомив, чи створено домен.", // ── Administration: accounts ─────────────────────────────────── + "Only on a device you've marked as your own. Sign in again with \u201cThis is my own device\u201d ticked.": "Лише на пристрої, позначеному як ваш. Увійдіть знову, позначивши «Це мій власний пристрій».", "Change your own password in {settings}.": "Власний пароль можна змінити в розділі {settings}.", "Administration": "Адміністрування", "Directory": "Каталог", diff --git a/web/src/locales/zh-Hans.ts b/web/src/locales/zh-Hans.ts index 235f41c..0ad4b9c 100644 --- a/web/src/locales/zh-Hans.ts +++ b/web/src/locales/zh-Hans.ts @@ -103,6 +103,7 @@ export const catalog: Catalog = { "also {names}": "别名:{names}", "The server did not say whether the domain was created.": "服务器没有说明域名是否已创建。", // ── Administration: accounts ─────────────────────────────────── + "Only on a device you've marked as your own. Sign in again with \u201cThis is my own device\u201d ticked.": "仅限在您标记为自己设备的设备上使用。请勾选「这是我自己的设备」后重新登录。", "Change your own password in {settings}.": "请在{settings}中更改您自己的密码。", "Administration": "管理", "Directory": "目录", diff --git a/web/src/views/AppShell.tsx b/web/src/views/AppShell.tsx index 396ad75..aa5de99 100644 --- a/web/src/views/AppShell.tsx +++ b/web/src/views/AppShell.tsx @@ -45,6 +45,7 @@ export function AppShell({ children }: { children: ReactNode }) { const appName = useSession((s) => s.session?.ihasmail?.appName) || DEFAULT_APP_NAME; const acctMenu = useMenu(); const administers = hasAdministration(usePermissions()); + const needsOwnDevice = useSession((s) => Boolean(s.session?.ihasmail?.administrationNeedsOwnDevice)); /* * "Go to folder" (#233), hosted here rather than in the mail view because * the `g` shortcuts are global: pressing it from the calendar should still @@ -162,6 +163,21 @@ export function AppShell({ children }: { children: ReactNode }) { {/* Only for an account whose Stalwart role manages other accounts. Nobody else is shown an entry that would open onto refusals. */} {administers && } label={t("Administration")} active={section === "admin"} onClick={() => navigate("/admin")} />} + {/* An administrator who signed in without "This is my own device". The + server withholds administration from that session, so the entry is + shown dead with the reason, rather than gone without one. */} + {!administers && needsOwnDevice && ( + } + disabled + label={ + <> + {t("Administration")} + {t("Only on a device you've marked as your own. Sign in again with “This is my own device” ticked.")} + + } + /> + )} } label={t("Refresh")} onClick={() => window.location.reload()} /> } label={t("Sign out")} onClick={() => void logout()} />