Plugins that talk to Nextcloud, Paperless or an AI provider used to need
the password or key in their own settings, readable by the plugin and
stored in the browser. A plugin can now declare credentials in its
manifest and send requests "with credential X" through the server:
- `credentials` in the manifest: admin scope (secret and server from the
admin config) or user scope (the user connects an account; the server
address is fixed or chosen by the user)
- `/api/plugin-secrets`: the user's connected accounts, encrypted per
mail account under `PLUGIN_SECRETS_DIR`; listing never returns secrets
- `/api/plugin-proxy`: adds the secret and forwards only to the
credential's own origin, through the SSRF guard unless the admin set
the server up or listed it in `PLUGIN_PROXY_PRIVATE_ORIGINS`; no
redirects or WebDAV moves to other servers, streamed bodies capped at
512 MB up and 2 GB down, 300 requests a minute per plugin, 8 at once
per user, and one audit line per request without paths or bodies
- a capture mode that stores a secret from a sign-in response (Nextcloud
Login Flow v2) and removes it from what the plugin gets back
The app refuses a JMAP server over plain http (loopback aside), yet the
desktop issued a code, reported "Signed in on your phone" once it was
redeemed, and the phone failed. Linking now stops before the step-up
with an explanation.
- An app password minted for a phone that never redeemed its code stayed
valid (no expiry, full permissions, no TOTP) and used up one of the
account's five app-password slots. It is now deleted, with its own
credential, when its grant is discarded or expires.
- Replaced codes were kept for twelve minutes each, so a session could
grow the store, and the cost of every request, without bound; only the
last three of a grant are kept now.
- A session read that failed after the credential check was taken for a
non-Stalwart server and handed the phone the account password; it now
refuses the pairing instead.
The step-up's failure count was keyed on the cookie slot, which the
client chooses, so a session could spread guesses over 50 slots; and
even one key allowed 480 failures a day, well above Stalwart's ban of
100 a day per address, which would ban the webmail's own IP for every
user. Failures now count per server and login name, 5 per 15 minutes
and 20 a day.
"Link Mobile App" only appeared for OAuth sessions, its step-up could
only go through the identity provider (which failed without an OAuth
client and bounced password+TOTP users to the provider's login page),
and it handed the phone the desktop's own refresh token. Revoking that
token on sign-out, or a rotating provider, signed the phone out too; a
confidential client stopped the phone at its first token expiry; and an
identity provider on another host was refused by the app.
The step-up now mints the phone's own grant:
- Password and password+TOTP accounts confirm the current password (and
code) in the dialog. The server uses it once to get an OAuth grant
from Stalwart's structured login for the app's own client, retries
with the webmail's client when the server insists on a registered one,
and falls back to an app password ("Bulwark mobile (paired <date>)")
or, on servers that offer neither, the verified password. Failed
attempts are limited per account so the webmail's address cannot be
banned by guessing through it.
- SSO accounts keep the provider round trip, now with `offline_access`
where offered; its fresh tokens become the phone's grant instead of
being thrown away, and must belong to the account being paired.
- The account and server always come from the slot's verified session
context, never from the request.
Grants live 5 minutes server-side, codes 2 minutes, both single use; the
store is pinned on `globalThis`. Redeem answers `invalid_code`,
`expired_code` or `used_code`, and the desktop polls a separate status
id to replace the QR with "Signed in on your phone". A phone whose
client is confidential, or whose provider is on another host, renews
through `/api/auth/pair/token` with a sealed refresh token, so current
app builds work unchanged. Signing out discards a pending grant but no
longer affects a paired phone.
The dialog shows for every account (not in Lite), with a copyable
sign-in link, a TOTP field when needed, and the app's own "Scan a
sign-in code" wording.
With custom JMAP endpoints allowed, the password+TOTP login posted the
admin's client secret to `/auth/token` on whatever server the user
named. The secret now only goes to admin-configured servers.
The password+TOTP login drove Stalwart's structured login (`/api/auth`,
then `/auth/token` with PKCE) inline. Linking the mobile app needs the
same exchange to mint a phone's own grant, so it now lives in
`lib/auth/stalwart-password-login.ts` and the route only maps its
result to the responses it always gave.
Signing out of Lite only deleted the refresh token from web storage, so
a copy taken from the browser kept working until it expired. Sign-out
now revokes it at the endpoint the mail server (or, for an external
provider, its discovery document) advertises, once the token is gone
from storage.
A password login whose token exchange failed for any reason fell back to
Basic auth and kept the password in sessionStorage. The password is now
only kept for the tab when the server has no token login at all (or
cannot be reached for it); otherwise the Basic session lives in memory
and a reload asks to sign in again.
Several endpoints answered anyone with what fingerprints a deployment:
`/api/health?detailed=true` (versions, memory), the update status
(exact version and the text of a pending security advisory, even with
`loginShowVersion` off), `/api/plugins` (installed plugins and the hosts
they may reach), the admin policy (push relays, sidebar app URLs,
plugin lists) and `/api/config` (every domain each server serves).
Visitors without a session now get:
- the plain liveness answer from `/api/health`;
- the update status only where the login page shows the version, and
never the advisory text;
- a 401 from `/api/plugins`, which the client only syncs after signing
in;
- the policy without plugin lists, push relays and sidebar apps, marked
`X-Bulwark-Policy-Scope: public`; the client fetches the full policy
once it signs in;
- server domain lists only when servers are picked by domain.
A mailbox session, a remembered session cookie or the admin session
counts as signed in. Deployments without a session secret cannot tell
sessions apart and keep the old answers.
`/api/auth/verify` checked any username and password against the JMAP
server without a limit. Every probe reaches the server from the
webmail's own address, so the route was a password oracle and enough
wrong passwords could trip Stalwart's authentication ban against the
webmail itself, locking out every user.
Wrong passwords now use up a budget of five per client and thirty
across all clients per fifteen minutes; right ones are refunded. Over
budget the route answers `inconclusive` and the browser probes the JMAP
server itself, as it did before the pre-check existed.
Bulwark Lite deleted the remembered refresh token on any non-5xx error
from `/auth/token`, so a 429 or 408 during a busy moment signed the
user out for good. Like the server build, it now clears the token only
when the server refuses it (400, 401, 403) and treats everything else
as an outage.
A Bearer login may claim a bare local part, which is accepted when the
server's `Session.username` has the same local part (TOTP users who
type `john` log in this way). Settings were keyed on that claim, so on a
multi-domain server `john@b.example` could claim `john` and read and
overwrite the synced settings of the `john@a.example` who signs in as
`john` with a password.
`resolveJmapIdentity` now returns the canonical account name for a bare
Bearer claim, the context cookie carries it as `accountName`, and the
settings route keys on it. Basic logins and fully-qualified claims keep
their existing key.
An impersonation handoff sealed `<target>%<master>` and the master
password into the session cookie, and `PUT /api/auth/session` hands that
cookie's contents to the browser on every restore. Anyone who received
a handoff could read a credential that signs in as every mailbox and
into Stalwart administration, with no expiry and no audit trail.
The impersonate route now uses the master credential server-side, once,
to create an app password on the target mailbox that expires after
8 hours. Only that app password reaches the cookies. Signing out
revokes it through a sealed grant cookie, and PUT refuses any session
that still holds the configured master password, so cookies minted
before this change are dropped instead of returned.