Look for Stalwart's capability where Stalwart advertises it
Self-service credentials, the About page and Files all keyed off `urn:stalwart:jmap`, and all three looked for it in the session-level `capabilities`. Stalwart has never put it there. `Session::new` builds that list from a fixed set the capability is not part of, in any 0.16.x from 0.16.0 to 0.16.19; it is handed out per-account instead, so it arrives in `primaryAccounts` and in each account's `accountCapabilities`. So every real 0.16 server read as pre-0.16. Password changes, 2FA and app passwords fell back to `POST /api/account/auth`, which 0.16 removed, and reported that the server offers no self-service credential management. About named the wrong generation. Files ran the pre-0.16 path, omitting `nodeType` and listing the tree through get. Look in all three places, on both sides. Two nearby soft spots go with it: a transport error while probing the registry no longer downgrades a server to the legacy path -- which would have posted the current password to an endpoint that is not there -- and a locale request that is merely refused no longer discards a generation the capability had already settled. The mock advertised the capability in the session, which is why no test ever caught this; it now advertises it where the real server does, and validates `using` by the urn rather than by the session, as Stalwart does. Put the old lookup back and nine tests fail. Stalwart still publishes no version number to clients -- VERSION_PUBLIC is a fixed "1.0.0" -- so About continues to report the generation and edition, which are now the right ones.
This commit is contained in:
@@ -46,6 +46,21 @@ after(() => {
|
||||
(mock as { server?: { close(): void } }).server?.close();
|
||||
});
|
||||
|
||||
/**
|
||||
* What the About page reads. Stalwart advertises `urn:stalwart:jmap` only
|
||||
* per-account, so a session that looks for it at the top level reports a real
|
||||
* 0.16 server as older than 0.16 — the same mistake that sent credentials to
|
||||
* the removed REST endpoint.
|
||||
*/
|
||||
test("the session reports the 0.16 generation the server actually is", async () => {
|
||||
const res = await call("/api/auth/session");
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(res.body.ihasmail.server.generation, "0.16+");
|
||||
assert.equal(res.body.ihasmail.server.edition, "oss");
|
||||
assert.equal(res.body.capabilities["urn:stalwart:jmap"], undefined, "not where a client would first look");
|
||||
assert.ok("urn:stalwart:jmap" in res.body.primaryAccounts, "but here, as on a real server");
|
||||
});
|
||||
|
||||
test("the 0.16 registry backend is detected and reported empty", async () => {
|
||||
const res = await call("/api/account/security");
|
||||
assert.equal(res.status, 200);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { config } from "./config.js";
|
||||
import { absoluteUpstream, UpstreamError, type UpstreamSession } from "./upstream.js";
|
||||
import { absoluteUpstream, hasStalwartRegistry, UpstreamError, type UpstreamSession } from "./upstream.js";
|
||||
import { generateSecret, otpauthUrl, parseOtpauthUrl, verifyTotp } from "./totp.js";
|
||||
import { randomBytes } from "node:crypto";
|
||||
|
||||
@@ -82,7 +82,7 @@ export async function detectBackend(sessionId: string, ctx: Ctx): Promise<Backen
|
||||
async function probeBackend(ctx: Ctx): Promise<Backend> {
|
||||
// A server with the registry answers x:AccountPassword/get; one without it
|
||||
// fails to parse the method name at all and returns unknownMethod.
|
||||
if (ctx.session.capabilities && STALWART_CAP in ctx.session.capabilities) {
|
||||
if (hasStalwartRegistry(ctx.session)) {
|
||||
try {
|
||||
const res = await jmap(ctx, [["x:AccountPassword/get", { accountId: accountId(ctx), ids: [SINGLETON] }, "p"]]);
|
||||
const [name, args] = res.methodResponses?.[0] ?? [];
|
||||
@@ -90,10 +90,14 @@ async function probeBackend(ctx: Ctx): Promise<Backend> {
|
||||
const type = (args as { type?: string } | undefined)?.type;
|
||||
if (type && type !== "unknownMethod") return "registry"; // present, but refused us
|
||||
} catch {
|
||||
// Not an answer we can read - most likely a server too old to know the
|
||||
// capability we named, which rejects the whole request rather than the
|
||||
// one call. Fall through and try the endpoint such servers do have.
|
||||
// The capability already told us this server has the registry, so a
|
||||
// request we could not read is a fault to surface, not evidence of an
|
||||
// older server. Falling back here would post the user's password to a
|
||||
// REST endpoint 0.16 removed and report the feature as unsupported.
|
||||
return "registry";
|
||||
}
|
||||
// It named the capability and then disowned the method: nothing else to try.
|
||||
return "registry";
|
||||
}
|
||||
return "legacy";
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { getAccountInfo, interpretAccountInfo } from "./upstream.js";
|
||||
import { getAccountInfo, hasStalwartRegistry, interpretAccountInfo } from "./upstream.js";
|
||||
|
||||
/**
|
||||
* The account locale used to be read only from `x:Account/get`, which needs
|
||||
@@ -65,3 +65,67 @@ test("no capabilities at all leaves the generation unknown", async () => {
|
||||
const info = await getAccountInfo("session-no-caps", "Basic x", { accounts: {}, primaryAccounts: {} } as never);
|
||||
assert.equal(info.generation, null);
|
||||
});
|
||||
|
||||
/**
|
||||
* Where Stalwart actually advertises `urn:stalwart:jmap`.
|
||||
*
|
||||
* Not in the session-level `capabilities`: `Session::new` builds those from a
|
||||
* fixed list that has never carried this capability, in any 0.16.x. It is
|
||||
* handed out per-account instead, so it lands in `primaryAccounts` and in each
|
||||
* account's `accountCapabilities`. Looking only at the session level called
|
||||
* every real 0.16 server pre-0.16, which sent self-service credentials to a
|
||||
* REST endpoint 0.16 had removed and made the About page report the wrong
|
||||
* generation.
|
||||
*/
|
||||
const STALWART = "urn:stalwart:jmap";
|
||||
const baseCaps = { "urn:ietf:params:jmap:core": {}, "urn:ietf:params:jmap:mail": {} };
|
||||
|
||||
test("a 0.16 server is recognised from primaryAccounts, where it advertises itself", () => {
|
||||
assert.equal(
|
||||
hasStalwartRegistry({ capabilities: baseCaps, accounts: {}, primaryAccounts: { [STALWART]: "a1" } }),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("a 0.16 server is recognised from an account's capabilities", () => {
|
||||
assert.equal(
|
||||
hasStalwartRegistry({
|
||||
capabilities: baseCaps,
|
||||
accounts: { a1: { accountCapabilities: { "urn:ietf:params:jmap:mail": {}, [STALWART]: {} } } },
|
||||
primaryAccounts: {},
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("the session level still counts, for a server that ever advertises it there", () => {
|
||||
assert.equal(hasStalwartRegistry({ capabilities: { ...baseCaps, [STALWART]: {} }, accounts: {}, primaryAccounts: {} }), true);
|
||||
});
|
||||
|
||||
test("a server that advertises it nowhere is pre-0.16", () => {
|
||||
assert.equal(hasStalwartRegistry({ capabilities: baseCaps, accounts: { a1: { accountCapabilities: baseCaps } }, primaryAccounts: { "urn:ietf:params:jmap:mail": "a1" } }), false);
|
||||
assert.equal(hasStalwartRegistry(undefined), false);
|
||||
});
|
||||
|
||||
test("a shared account carrying the capability is enough to recognise the server", () => {
|
||||
assert.equal(
|
||||
hasStalwartRegistry({
|
||||
capabilities: baseCaps,
|
||||
accounts: { a1: { accountCapabilities: baseCaps }, a2: { accountCapabilities: { [STALWART]: {} } } },
|
||||
primaryAccounts: {},
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("a locale request that fails does not talk us out of a generation we proved", () => {
|
||||
// The capability settled it. A forbidden reply costs the locale, nothing more.
|
||||
const info = interpretAccountInfo([failed("s", "forbidden"), failed("a", "forbidden")], "0.16+");
|
||||
assert.equal(info.generation, "0.16+");
|
||||
assert.equal(info.locale, null);
|
||||
});
|
||||
|
||||
test("a server that disowns the method is still older, whatever we came in believing", () => {
|
||||
const info = interpretAccountInfo([failed("s", "unknownMethod")], "0.16+");
|
||||
assert.equal(info.generation, "pre-0.16");
|
||||
});
|
||||
|
||||
@@ -544,8 +544,8 @@ function readBody(req: IncomingMessage): Promise<Buffer> {
|
||||
}
|
||||
|
||||
const session = () => ({
|
||||
capabilities: { "urn:ietf:params:jmap:core": { maxSizeUpload: 50000000, maxConcurrentUpload: 4, maxSizeRequest: 10000000, maxConcurrentRequests: 4, maxCallsInRequest: 16, maxObjectsInGet: MAX_OBJECTS, maxObjectsInSet: MAX_OBJECTS, collationAlgorithms: ["i;ascii-casemap"] }, "urn:ietf:params:jmap:mail": {}, "urn:ietf:params:jmap:submission": {}, "urn:ietf:params:jmap:vacationresponse": {}, "urn:ietf:params:jmap:sieve": { implementation: "mock" }, "urn:ietf:params:jmap:calendars": {}, "urn:ietf:params:jmap:calendars:parse": {}, "urn:ietf:params:jmap:contacts": {}, "urn:ietf:params:jmap:contacts:parse": {}, "urn:ietf:params:jmap:principals": {}, "urn:ietf:params:jmap:principals:availability": {}, "urn:ietf:params:jmap:quota": {}, "urn:ietf:params:jmap:blob": {}, "urn:ietf:params:jmap:filenode": {}, ...(LEGACY ? {} : { "urn:stalwart:jmap": {} }) },
|
||||
accounts: { [ACCOUNT]: { name: USER, isPersonal: true, isReadOnly: false, accountCapabilities: { "urn:ietf:params:jmap:mail": {}, "urn:ietf:params:jmap:submission": {}, "urn:ietf:params:jmap:vacationresponse": {}, "urn:ietf:params:jmap:sieve": {}, "urn:ietf:params:jmap:calendars": {}, "urn:ietf:params:jmap:contacts": {}, "urn:ietf:params:jmap:principals": {}, "urn:ietf:params:jmap:quota": {}, "urn:ietf:params:jmap:filenode": {} } } },
|
||||
capabilities: { "urn:ietf:params:jmap:core": { maxSizeUpload: 50000000, maxConcurrentUpload: 4, maxSizeRequest: 10000000, maxConcurrentRequests: 4, maxCallsInRequest: 16, maxObjectsInGet: MAX_OBJECTS, maxObjectsInSet: MAX_OBJECTS, collationAlgorithms: ["i;ascii-casemap"] }, "urn:ietf:params:jmap:mail": {}, "urn:ietf:params:jmap:submission": {}, "urn:ietf:params:jmap:vacationresponse": {}, "urn:ietf:params:jmap:sieve": { implementation: "mock" }, "urn:ietf:params:jmap:calendars": {}, "urn:ietf:params:jmap:calendars:parse": {}, "urn:ietf:params:jmap:contacts": {}, "urn:ietf:params:jmap:contacts:parse": {}, "urn:ietf:params:jmap:principals": {}, "urn:ietf:params:jmap:principals:availability": {}, "urn:ietf:params:jmap:quota": {}, "urn:ietf:params:jmap:blob": {}, "urn:ietf:params:jmap:filenode": {} },
|
||||
accounts: { [ACCOUNT]: { name: USER, isPersonal: true, isReadOnly: false, accountCapabilities: { "urn:ietf:params:jmap:mail": {}, "urn:ietf:params:jmap:submission": {}, "urn:ietf:params:jmap:vacationresponse": {}, "urn:ietf:params:jmap:sieve": {}, "urn:ietf:params:jmap:calendars": {}, "urn:ietf:params:jmap:contacts": {}, "urn:ietf:params:jmap:principals": {}, "urn:ietf:params:jmap:quota": {}, "urn:ietf:params:jmap:filenode": {}, ...(LEGACY ? {} : { "urn:stalwart:jmap": {} }) } } },
|
||||
primaryAccounts: { ...Object.fromEntries(["mail", "submission", "vacationresponse", "sieve", "calendars", "contacts", "principals", "quota", "filenode", "blob"].map((c) => [`urn:ietf:params:jmap:${c}`, ACCOUNT])), ...(LEGACY ? {} : { "urn:stalwart:jmap": ACCOUNT }) },
|
||||
username: USER,
|
||||
apiUrl: `http://127.0.0.1:${PORT}/jmap/`,
|
||||
@@ -607,7 +607,12 @@ export const server = createServer(async (req, res) => {
|
||||
const body = JSON.parse((await readBody(req)).toString()) as { methodCalls: [string, Obj, string][]; using?: string[] };
|
||||
// A capability the server cannot parse fails the whole request, not the one
|
||||
// call that wanted it - which is why an over-eager `using` is so damaging.
|
||||
const unknown = (body.using ?? []).find((u) => !(u in session().capabilities));
|
||||
// Stalwart decides this by parsing the urn, not by looking it up in the
|
||||
// session, so a capability it hands out per-account is still usable here:
|
||||
// `urn:stalwart:jmap` never appears in the session-level capabilities and
|
||||
// the registry calls that name it work all the same.
|
||||
const known = new Set([...Object.keys(session().capabilities), ...Object.keys(session().accounts[ACCOUNT]?.accountCapabilities ?? {})]);
|
||||
const unknown = (body.using ?? []).find((u) => !known.has(u));
|
||||
if (unknown) {
|
||||
res.writeHead(400, { "content-type": "application/json" });
|
||||
return res.end(JSON.stringify({ type: "urn:ietf:params:jmap:error:unknownCapability", status: 400, detail: `Unknown capability: ${JSON.stringify(unknown)}` }));
|
||||
|
||||
+39
-5
@@ -69,6 +69,30 @@ export function forgetUpstreamSession(sessionId: string): void {
|
||||
const STALWART_CAP = "urn:stalwart:jmap";
|
||||
const JMAP_CORE = "urn:ietf:params:jmap:core";
|
||||
|
||||
/**
|
||||
* Whether this server has Stalwart's JMAP registry — the `x:` objects that
|
||||
* carry credentials, account settings and the newer FileNode shape.
|
||||
*
|
||||
* `urn:stalwart:jmap` is the marker, but **not** in the session-level
|
||||
* `capabilities`, which is where a JMAP client would naturally look. Stalwart
|
||||
* builds that list from a fixed set that has never included this capability;
|
||||
* it hands it out per-account instead, so it turns up in `primaryAccounts` and
|
||||
* in each account's `accountCapabilities`. Checking only the session level
|
||||
* therefore reports every real 0.16 server as pre-0.16 — which routed
|
||||
* self-service credentials to a REST endpoint 0.16 had removed, and told the
|
||||
* About page the wrong thing. The session level is still checked last, in case
|
||||
* a later release advertises it there as well.
|
||||
*/
|
||||
export function hasStalwartRegistry(session: Pick<UpstreamSession, "capabilities" | "accounts" | "primaryAccounts"> | undefined): boolean {
|
||||
if (!session) return false;
|
||||
if (session.primaryAccounts && STALWART_CAP in session.primaryAccounts) return true;
|
||||
for (const account of Object.values(session.accounts ?? {})) {
|
||||
const caps = (account as { accountCapabilities?: Record<string, unknown> } | null)?.accountCapabilities;
|
||||
if (caps && STALWART_CAP in caps) return true;
|
||||
}
|
||||
return Boolean(session.capabilities && STALWART_CAP in session.capabilities);
|
||||
}
|
||||
|
||||
export interface AccountInfo {
|
||||
/** BCP-47 tag configured for the account, or null if unreadable. */
|
||||
locale: string | null;
|
||||
@@ -87,6 +111,7 @@ const INFO_CACHE_MS = 30 * 60_000;
|
||||
const EMPTY_INFO: AccountInfo = { locale: null, generation: null, edition: null };
|
||||
/** A server that has never heard of the registry: nothing to read, but dated. */
|
||||
const PRE_REGISTRY_INFO: AccountInfo = { locale: null, generation: "pre-0.16", edition: null };
|
||||
const REGISTRY_INFO: AccountInfo = { locale: null, generation: "0.16+", edition: null };
|
||||
|
||||
/**
|
||||
* glibc modifiers that name a script rather than a dialect or a currency:
|
||||
@@ -144,8 +169,9 @@ async function fetchAccountInfo(authorization: string, session: UpstreamSession)
|
||||
// at all, so its absence already answers the question — and asking anyway
|
||||
// would fail the whole request, since those servers reject a `using` naming
|
||||
// a capability they cannot parse.
|
||||
// A session with no capabilities at all is not one we can read anything from.
|
||||
if (!session.capabilities) return EMPTY_INFO;
|
||||
if (!(STALWART_CAP in session.capabilities)) return PRE_REGISTRY_INFO;
|
||||
if (!hasStalwartRegistry(session)) return PRE_REGISTRY_INFO;
|
||||
const accountId =
|
||||
session.primaryAccounts?.[STALWART_CAP] ??
|
||||
session.primaryAccounts?.["urn:ietf:params:jmap:mail"] ??
|
||||
@@ -163,9 +189,12 @@ async function fetchAccountInfo(authorization: string, session: UpstreamSession)
|
||||
}),
|
||||
signal: AbortSignal.timeout(config.upstreamTimeout),
|
||||
});
|
||||
if (!res.ok) return EMPTY_INFO;
|
||||
// The registry capability already settled the generation. A locale request
|
||||
// that fails — a permission we lack, a hiccup upstream — can only cost us the
|
||||
// locale; it must not talk us out of what we know.
|
||||
if (!res.ok) return REGISTRY_INFO;
|
||||
const body = (await res.json()) as { methodResponses?: [string, Record<string, unknown>, string][] };
|
||||
return interpretAccountInfo(body.methodResponses ?? []);
|
||||
return interpretAccountInfo(body.methodResponses ?? [], "0.16+");
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -173,16 +202,21 @@ async function fetchAccountInfo(authorization: string, session: UpstreamSession)
|
||||
* back to `x:Account` for servers (or permissions) where only that one works,
|
||||
* and note which generation answered.
|
||||
*/
|
||||
export function interpretAccountInfo(responses: [string, Record<string, unknown>, string][]): AccountInfo {
|
||||
export function interpretAccountInfo(
|
||||
responses: [string, Record<string, unknown>, string][],
|
||||
known: AccountInfo["generation"] = null,
|
||||
): AccountInfo {
|
||||
const settings = responses.find((r) => r[2] === "s");
|
||||
const account = responses.find((r) => r[2] === "a");
|
||||
// Only 0.16+ knows the method at all; older builds cannot even parse the name.
|
||||
// `known` is what the session capability already proved, and outranks a reply
|
||||
// that merely refused us.
|
||||
const generation: AccountInfo["generation"] =
|
||||
settings && settings[0] !== "error"
|
||||
? "0.16+"
|
||||
: (settings?.[1] as { type?: string } | undefined)?.type === "unknownMethod"
|
||||
? "pre-0.16"
|
||||
: null;
|
||||
: known;
|
||||
return { locale: localeOf(settings) ?? localeOf(account), generation, edition: null };
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user