831 Commits
Author SHA1 Message Date
Linus Rath 2522dbd804 fix: keep the plugin denylist and unit tests from breaking Lite and Docker builds 2026-09-26 21:38:57 +02:00
Linus Rath 09e0e2cfb3 fix: show events you declined struck through in the calendar #1110 2026-09-26 21:32:08 +02:00
Linus Rath 078e9f9676 feat: show tasks with due dates in the calendar month view #1107 2026-09-26 20:47:13 +02:00
Linus Rath 7b25d3a95b fix: keep message list from jumping while attachment chips load 2026-09-26 20:24:23 +02:00
Linus Rath eb7b6f8dab fix: point Stalwart installs at a tagged Lite release and publish checksums
The Stalwart bundle's README told admins to use
`releases/latest/download/bulwark-lite-stalwart.zip` as `resourceUrl`.
Stalwart re-downloads that URL on its own schedule and checks no hash or
signature, so whatever the newest release held would run as script on
the Stalwart origin next to the web admin.

The README now installs from the tagged release, explains updating by
moving `resourceUrl` to the next tag, and describes the floating URL as
an opt-in with that risk spelled out. Release runs upload a `.sha256`
next to each Lite zip.
2026-09-26 17:30:47 +02:00
Linus Rath 6ff94d439e fix: pin Lite pages to their own inline scripts and guard the Stalwart bundle
The static export's header policy has to allow inline scripts (the
hydration scripts differ per page and one header cannot list them all),
so any injected inline script ran. The Stalwart bundle had no policy at
all: Stalwart sends no security headers for an Application, and nothing
stopped another site from framing it.

- Postbuild gives every page of the static target a `<meta>` CSP that
  allows exactly the page's own inline scripts; enforced together with
  the host's header, it takes away `'unsafe-inline'`. `verify` fails a
  page whose inline scripts are not all pinned.
- The Stalwart entry document carries a `<meta>` CSP (no plugins, eval,
  foreign scripts or moved `<base>`; its shells are rewritten at runtime,
  so hashes are not possible there) and refuses to run inside a frame
  of another origin.
- The nginx and Caddy examples send the same CSP as `_headers`.
2026-09-26 17:27:00 +02:00
Linus Rath d155cead41 fix: revoke Lite refresh tokens on sign-out and keep no failed-login password
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.
2026-09-26 17:15:55 +02:00
Linus Rath c329d486a7 fix: keep the mock JMAP server out of release builds
The mock JMAP server behind `/api/dev-jmap` accepts any password and
shipped in every image, held back only by `DEV_MOCK_JMAP=true` at
runtime. It now also needs the flag at build time (inlined through
`next.config.ts`), so a build made without it cannot switch the mock on,
and the Docker image drops the route before building.
2026-09-26 17:07:31 +02:00
Linus Rath 05fe7f8780 fix: stop handing instance details to visitors who are not signed in
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.
2026-09-26 17:03:24 +02:00
Linus Rath 47342e9e08 fix: clear the accounts' leftovers when everyone signs out
A full sign-out emptied the stores but left the accounts' traces in the
browser for the next person at the computer: search history, open tabs
with message subjects, Files favourites and recent files, calendar
alert acknowledgements, and attachment bytes a plugin had staged for an
upload that never started. Other open tabs kept showing the signed-out
accounts' mail, and the boot snapshot of the mailbox list was restored
for whichever account happened to be active next.

- A full sign-out now removes those keys and the staged files, and
  tells the other tabs through a `storage` event so they leave for the
  login page.
- The boot snapshot names its account and is only restored for it.
- The login form only remembers the username for suggestions when the
  user did not decline "remember me".

Device preferences and `template-storage` stay: with settings sync off
they exist only on this browser.
2026-09-26 16:55:34 +02:00
Linus Rath 0040e5daca fix: keep plugin storage per account and delete it on sign-out
`api.storage` kept one `plugin:<id>:` namespace for everyone signed in
to the browser and left it behind after signing out, so a plugin read
what it had stored for the previous account, such as quick-notes'
notes on someone else's mail.

Keys now carry the account (`plugin:<id>:@<account>:<key>`). Signing an
account out or removing it deletes its keys, and a full sign-out
deletes all plugin storage. Keys written before this change are handed
to the first account that uses the plugin. Uninstalling a plugin still
removes everything under its prefix.
2026-09-26 16:47:29 +02:00
Linus Rath 204cd09951 fix: end office editor sessions when the user signs out
A WOPI token carries the user's credentials to the editor server and
stayed valid for its whole six-hour lifetime, so an editor opened
before signing out could still read and save the document afterwards.

Each token now records the browser (a random id in an HttpOnly cookie,
hashed) and account slot that opened it. Signing out records a
revocation time for that browser and slot, or for every slot on a full
sign-out, and WOPI calls with a token minted before it are refused.
Signing out on another device leaves this browser's editors alone.
Revocations live in the state directory, so they survive a restart and
reach replicas sharing the state volume, and are dropped once the
tokens they cover have expired.
2026-09-26 16:40:46 +02:00
Linus Rath a2253982e5 fix: limit wrong passwords tried through the login pre-check
`/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.
2026-09-26 16:36:58 +02:00
Linus Rath 5622eca36b fix: refuse a used impersonation link after a restart or on another replica
Used impersonation token ids were only remembered in process memory, so
a captured handoff link could be redeemed a second time within its five
minute lifetime once the process restarted, or on another replica. Each
used id now also leaves a marker file in the state directory, created
exclusively, which every replica sharing the state volume sees; markers
are pruned once the token could no longer verify.

The absolute session limit comes from the eight-hour app password that
Stalwart itself expires.
2026-09-26 16:34:58 +02:00
Linus Rath 034ee27057 fix: keep the setup token out of access logs, referers and history
The startup banner printed `/setup?token=...`, so the one-hour bootstrap
token that grants the setup wizard landed in reverse-proxy access logs,
Referer headers and browser history. The link now carries the token in
the fragment, which never leaves the browser, and the wizard removes it
from the address bar as soon as it reads it. Old `?token=` links still
work and are cleaned up the same way.
2026-09-26 16:32:38 +02:00
Linus Rath 3ac6c25c71 fix: keep sibling subdomains from widening the sidebar-app frame-src
The inline sidebar apps hand their origins to the proxy through a
client-written cookie, which the proxy merges into `frame-src`. Any
same-site sibling host could set that cookie too. Over HTTPS the cookie
is now `__Host-bulwark_app_frame_origins`, which only this exact host
can set, and the proxy ignores the unprefixed name there. Plain HTTP
(local development) keeps the old name.
2026-09-26 16:27:03 +02:00
Linus Rath 0ccb533405 fix: serve sender favicons only as inert raster images
`/api/favicon` is unauthenticated and answers on the webmail origin,
and it passed the upstream Content-Type through unchecked. Should the
favicon service ever return `image/svg+xml` or `text/html`, opening
that URL on its own would run script as the webmail. Only raster image
types are served now, with `nosniff` and a sandboxing CSP; anything
else falls back to the transparent placeholder.
2026-09-26 16:24:11 +02:00
Linus Rath 02703d8b3d fix: stop not-found pages outside the app tree from being framed
Paths the proxy skips (static files, `/_next`, `/api`) got no security
headers at all, so the not-found shell rendered for a missing
`/foo.html`, `/branding/x` or an unknown `/api` route could be framed
by any site. Skipped responses now carry `nosniff` and
`X-Frame-Options: DENY`, and non-API ones `frame-ancestors 'none'`;
API routes keep setting their own CSP.
2026-09-26 16:22:49 +02:00
Linus Rath e7dc992721 fix: never let ALLOWED_FRAME_ANCESTORS frame the admin dashboard or setup 2026-09-26 16:21:02 +02:00
Linus Rath f9af701bb9 fix: keep the Lite session when the token endpoint is only rate limiting
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.
2026-09-26 16:17:29 +02:00
Linus Rath 4858eb0915 fix: open a push notification's message in the account it came from
Clicking a new-mail notification opened `/mail/message/<id>` in
whichever account was active. With several accounts signed in, that
looked the id up in the wrong account: nothing opened, or a different
message did and was marked read.

The preview API now returns the cookie slot of the login that owns the
notified account, the service worker adds it to the permalink (or links
that account's Inbox for a group notification), and the app switches to
the login on that slot before opening the message.
2026-09-26 16:12:20 +02:00
Linus Rath efdc6a1dc3 fix: keep a reply draft in its thread when it is re-opened or undone
Drafts were saved without In-Reply-To and References, and a draft
re-opens in compose mode, where the composer has no original message to
rebuild them from. A reply that was saved and re-opened, or sent and
brought back with Undo, therefore went out as a new conversation.

`createDraft` now stores the threading headers, the composer saves them
with every draft of a reply, and re-opening a draft carries them into
the composer for later saves and the send.
2026-09-26 16:06:28 +02:00
Linus Rath e7a48f13f3 fix: report writes the server refused instead of showing them as done
Marking read, starring, tagging, emptying a folder and cancelling a
scheduled send only checked that the request came back. A `/set` the
server refused - `notUpdated`, `notDestroyed` or a method-level
`error` - resolved as if it had worked: the list showed the new state,
and a cancel the server rejected looked cancelled while the message
still went out.

These writes now go through one check that throws on any of those, so
the store keeps the old state and shows the server's reason.
2026-09-26 16:02:42 +02:00
Linus Rath 89ebb3a6ce fix: report a failed message-list read instead of an empty folder
`getEmails` and `getSomeEmails` caught every failure and answered with
an empty result. A refresh during a server hiccup therefore emptied the
message list, and a push delta whose follow-up read failed treated every
updated row as gone from the folder while still moving the sync state
past those changes, so the missed mail only came back on a full reload.

Both now throw. A refresh that fails keeps the list on screen, a delta
that cannot be read falls back to the full refresh, and the unified
views already record per-account failures instead of showing nothing.
2026-09-26 15:57:11 +02:00
Linus Rath bdd79648b0 fix: stop a quoted sender name from adding a recipient to a draft
`formatRecipient` writes a quote inside a display name as `\"`, but
the parsers that read recipient strings back took every `"` as the end
of the quoted name. A sender named `Support", ceo@corp.example, "x`
came back from the draft stash (or the dirty check) as three
recipients, one of them an address the user never chose. The
splitter and its helpers now skip escaped characters inside quotes.
2026-09-26 15:48:27 +02:00
Linus Rath 34fb9d247a fix: keep daily recurring events going past a DST gap
Daily expansion advanced its period anchor day by day and kept the
time a DST gap pushed it to: 02:30 became 03:30 on the spring-forward
night, and an all-day event's midnight became 01:00 where the change
happens at midnight (Chile, Brazil). Every later day then failed the
implicit byHour, so the series vanished from that night on.

Each daily candidate is now built from the start's wall-clock time on
its day and matched against that time, so the gap day still shows its
occurrence (moved past the gap) and the days after it are back at the
normal time.
2026-09-26 15:45:59 +02:00
Linus Rath 7676975f7e fix: page each account of a merged list from its own position
The unified inbox, cross-account views, tag view and "All folders"
search load the next page of every account with the merged list's
length as its position. The list interleaves the accounts, so each
account skipped the rows between its own count and the merged length:
two accounts of 120 mails showed 140 after scrolling to the end.

The fan-out loaders now take a per-account position map, and load
more passes the number of rows each account already has in the list.
2026-09-26 15:43:32 +02:00
Linus Rath 04010ce41b fix: split the state poll to fit the server's call limit
The state poll puts two method calls per session account, plus
calendars and filters, into one request. From seven accounts on (the
server's default limit is 16 calls) Stalwart refused the whole request,
and the failure was swallowed, so new mail and folder changes stopped
showing up for users with many shared accounts. The poll now goes out
in batches of `maxCallsInRequest`, one after the other.
2026-09-26 15:39:48 +02:00
Linus Rath 9033aa7cca fix: reset the account security page when switching accounts
The Account Security store was never reset on an account switch, and
the settings page only fetches while it has no verdict yet. After a
switch it kept showing the previous account's 2FA state, app passwords
and API keys, and "Remove" sent that account's credential ids through
the new account's session.

`clearAllStores` now resets the store, so the page re-probes and loads
the new account, and its fetches drop answers that arrive after the
stores were cleared for another account.
2026-09-26 15:34:13 +02:00
Linus Rath e98a0e6351 fix: drop folder and filter fetches that finish after an account switch
A folder or filter fetch started for one account and answered after a
switch wrote its result into the stores the next account had just
filled. The sidebar then showed the old account's folders, and the
filter store kept the old account's rules and script id, so the next
save uploaded them - a redirect to someone else's address included -
to the new account's server.

Clearing the stores for another account now bumps a store epoch.
`fetchMailboxes` and the email list's view check drop results from an
older epoch, and the filter store drops any fetch that is no longer the
latest one.
2026-09-26 15:31:05 +02:00
Linus Rath a5a06bb25f fix: never replay a JMAP write after a dropped connection
A network error on a JMAP request was retried once after a second.
Timeouts were already excluded, but a connection that failed after the
request was written (a reset, a proxy dropping the answer) still
replayed the whole batch, so one click on Send could queue two
submissions and deliver the mail twice. Only requests that change
nothing are retried now: GETs, blob uploads and batches made only of
read methods.
2026-09-26 15:20:29 +02:00
Linus Rath 9f11210b6a fix: undo and edit a group-identity send in the group's own account
A message sent or scheduled from a shared or group identity lives in
that account. Undo send, "Edit" on a scheduled message and cancelling
one cancelled the submission in the right account (#874), but then
restored the message to Drafts, fetched and deleted it through the
login's primary account. That hit whichever primary message had the
same id - pulling an unrelated mail into Drafts or deleting it - and
left the unsent message sitting in the group's Sent.

`restoreEmailToDraft` now takes the account, and the store passes the
submission's account to restore, fetch and delete, using that
account's Drafts and Sent.
2026-09-26 15:18:20 +02:00
Linus Rath 4bbcf9cdd7 fix: check telemetry targets at connect time and fail closed
The telemetry sender and the Stalwart version probe checked the target
with their own DNS lookup and then fetched with a plain `fetch`, which
resolved the name again: a rebinding answer between the two reached an
internal host, and a lookup that failed at check time passed the check
outright. Both now connect through `fetchPublicUrl`, which validates
the address inside the socket's own lookup and fails when the lookup
does. The `BULWARK_TELEMETRY_ALLOW_PRIVATE` dev bypass keeps the plain
fetch.
2026-09-26 15:15:23 +02:00
Linus Rath 49e8d08c23 fix: never follow redirects on OAuth token and revocation requests
The code exchange, token refresh and revocation POSTs carry the
authorization code, refresh token and client secret, and used a plain
`fetch` that follows redirects. The endpoints come from a validated
discovery document, but a redirect from them (or from an endpoint that
changed after discovery) re-sent the body to a host nobody validated.
These requests now fail on any redirect. A failed refresh still keeps
the refresh token, as for any other outage.
2026-09-26 15:13:41 +02:00
Linus Rath 928633d087 fix: close remote-content gaps in the print view and the external-URL checks
Printing a message opened an about:blank popup that carries the app's
CSP (`img-src https:`), not the message iframe's strict one, so with
remote content blocked anything the DOM walk missed still loaded when
printing. The print document now carries the iframe CSP and
`no-referrer`.

The DOM walk itself missed several spellings the browser still loads:

- backslashes, which the URL parser reads as slashes (`/\host`,
  `https:\host`), and `https:host` without slashes
- an escaped `url` keyword in a style attribute (`\75 rl(`)
- `image-set()` with plain strings
- an unclosed `url(` at the end of a style attribute
2026-09-26 15:11:46 +02:00
Linus Rath b73d0dfabf fix: stop reflecting a user-chosen server's error body from the TOTP login
A failed TOTP login returned the first 500 bytes of the upstream error
body. With custom JMAP endpoints allowed and no server configured, the
upstream is any address the caller names, so the route read other
servers' responses on the caller's behalf. The detail is now only
passed on from an admin-configured server.
2026-09-26 15:08:35 +02:00
Linus Rath 32280b9cc1 fix: cap admin sign-in attempts across all clients (GHSA-7pj2-232x-6698)
The admin login limit keys on the client IP taken from
X-Forwarded-For. With no reverse proxy in front, that header is
whatever the client sends (Next only fills it from the socket when the
request carries none), so each forged address got a fresh bucket of
five guesses. Route handlers cannot see the socket address, so the
limiter now also stops at 50 attempts per 15 minutes across all
clients. `.env.example` says why a proxy that appends the header is
still needed for trustworthy per-IP limits and audit addresses.
2026-09-26 15:07:09 +02:00
Linus Rath 6d2255ad0d fix: keep plugin http.post away from credentialed and admin routes
`http.post` attaches the user's Authorization header to any same-origin
path the plugin's own manifest lists in `apiPostPaths`. A plugin with
only `http:post` could list the JMAP passthrough and get full JMAP
access, or reach `/api/admin/*`, `/api/settings` and the other
credentialed routes. The host now refuses those paths whatever the
manifest says, checked on the decoded path the router sees. Plugins
keep reaching their own sidecar routes.
2026-09-26 15:01:42 +02:00
Linus Rath 3f01e94319 fix: key synced settings on the account a bearer token belongs to
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.
2026-09-26 14:59:55 +02:00
Linus Rath e0bfdfa437 fix: stop filter rule data from breaking out into the Sieve script
The generator wrote several rule fields into the script unescaped. A
`*/` in a rule name ended the `@metadata` comment, a line break ended
the `# Rule:` comment, a custom header name could close its string, and
a size value was written as-is, so each could add live commands such as
`redirect "attacker@..."` to the active script. Rules reach the
generator from plugins holding `filters:write` and from imported filter
sets, not only from the editor.

The metadata JSON now escapes `*/` (JSON reads `\/` back as `/`), the
rule-name comment is kept on one line, header names are escaped like
other strings, and a size that is not a number becomes 0.
2026-09-26 14:56:01 +02:00
Linus Rath 6e94b1a330 fix: treat image-map links like anchors so they cannot keep the opener
`applyNewTabToAnchor` and the viewer's click interceptor only looked at
`<a>`. An `<area>` in an image map kept the target and `rel="opener"`
its sender wrote and took the browser's default click, so the opened
page held `window.opener` and could navigate the webmail tab (reverse
tabnabbing, top navigation in Firefox). `<area>` now gets the same
rel/target treatment and goes through the same click gate, in the
desktop viewer and in the conversation view.
2026-09-26 14:53:40 +02:00
Linus Rath 3ac2ab1d97 fix: keep remote images in a quoted reply from loading in the composer
The quoted original in a reply or forward renders in the app document
(a shadow root), where the viewer's iframe CSP does not apply. Opening
a reply therefore loaded every remote image and background of the
original, firing the trackers the viewer had just held back.

The quote's node view now parks remote references in
`data-bulwark-remote-*` attributes for display and restores them when
edits are synced back, so the sent reply still quotes the original as
written. Remote content shows when the policy is `allow` or the sender
is trusted.
2026-09-26 14:51:14 +02:00
Linus Rath 809db3b38d fix: block remote content in the mobile conversation view
The conversation view used on phones built its own sanitizer hook that
only caught `<img src>` starting with a lowercase scheme and inline
style `url()`, and its iframe CSP always allowed http(s) images. With
remote content blocked (the default `ask` policy) uppercase schemes,
srcset, `<source>`, video posters and background attributes still
loaded, so trackers fired when a phone user opened a thread.

The view now uses the desktop viewer's `blockExternalResourcesOnNode`,
and the strict iframe CSP is shared as `emailIframeCsp()` and applied
whenever remote content is blocked, together with `no-referrer`.
2026-09-26 14:45:20 +02:00
Linus Rath f0aa0eedab fix: show what a mailto unsubscribe sends and limit it to one recipient
A newsletter's List-Unsubscribe `mailto:` was sent from the user's own
account as written: every `to=` field became a recipient, and subject
and body were the sender's choice. The confirmation said only
"Unsubscribe from this sender?", so one click could mail
"I resign" to someone's boss and HR.

The unsubscribe now goes to the single address in the URL (a list of
addresses is refused and `to=` is ignored), the subject is kept on one
line and both subject and body are capped. Both confirmations show the
recipient, subject and body before anything is sent.
2026-09-26 14:41:14 +02:00
Linus Rath 6f1cad1c98 fix: close theme CSS sanitizer bypasses and enforce :root/.dark selectors
The theme sanitizer was a short denylist. It missed protocol-relative
`url(//host)`, schemes and keywords spelled with CSS escapes
(`url(\68ttps:...)`, `@\69mport`), `image-set("https://...")` and
`@font-face` sources, so a theme could load remote resources from the
app document. `validateThemeSelectors` was never called, so theme CSS
could also target `input[type=password]` or restyle real UI. The admin
routes and the zip validator only sanitized when the safety check
failed, and CSS returned by the `onThemeBeforeApply` plugin hook was
injected as-is.

- every `url()` except a `#fragment` and every `image-set()` is removed
  with its arguments; a banned construct spelled with escapes drops the
  whole stylesheet
- theme CSS keeps only `:root` and `.dark` rules (no nested rules),
  plus `@font-face`, `@keyframes` and `@media`/`@supports` wrappers
- sanitizing is unconditional everywhere, including plugin-transformed
  CSS; compiled token values may not contain escapes or `url()`
2026-09-26 14:36:02 +02:00
Linus Rath ad84588f6a fix: keep an SVG attachment thumbnail from running script in the webmail origin
Attachment thumbnails were object URLs typed with the sender's MIME
type. Object URLs share the webmail origin, so an `image/svg+xml`
attachment opened from its thumbnail ("Open image in new tab", or
dragged to the tab strip) ran the sender's script as the webmail in
Chromium and Firefox. The drag-out URL and Files thumbnails had the
same shape.

`fetchBlobAsObjectUrl` now re-types every blob to an inert type, and
image thumbnails in mail and Files go through `imageBlobUrl`, which
hands an SVG over as a `data:` URL: it still renders in the `<img>`,
but opened on its own it gets an opaque origin.
2026-09-26 14:30:19 +02:00
Linus Rath f98779b0d0 fix: require manifest permissions for plugin hooks and slots
Only the host API methods checked a plugin's permissions. Every hook
and slot the sandbox reported was wired as-is, so a plugin declaring no
permissions could rewrite recipients and body of every outgoing mail
through `onTransformOutgoingEmail`, replace displayed bodies through
`onRenderEmailBody`, or read all fetched mail through
`onEmailsFetched`. Admin-installed plugins are managed, so users never
saw a consent prompt for any of it.

Each hook and slot now maps to a permission in
`lib/plugin-sandbox/permissions.ts`, checked the same way as API calls
(declared, and managed or granted). Hooks that change what is sent need
`email:send`, display takeovers need `email:render-takeover`, and a hook
or slot missing from the table is refused. Logout and account switches
stay implicit so plugins can always drop per-account state.
2026-09-26 14:26:33 +02:00
Linus Rath 1f19796cc2 fix: ignore admin status from a server the admin never configured (GHSA-j867-89p4-v8hm)
The Stalwart admin check only bailed while `allowCustomJmapEndpoint` was
on. A context cookie minted while it was on keeps naming the
user-chosen server, and the envelope has no expiry, so after the admin
turned the switch off that server could still answer the admin probe
and mint a Bulwark admin session. The check now also requires the
cookie's server to be an admin-configured one.
2026-09-26 14:20:36 +02:00
Linus Rath 76154187fa fix: only let a Stalwart superuser into the admin dashboard, not a tenant admin (GHSA-v6hr-cmxm-pv73)
The Stalwart admin check probed `x:Account/query`. Stalwart's default
tenant-admin role carries `sysAccount*`, so an administrator of a single
tenant passed and, in the default `auto` mode, was signed into the
Bulwark dashboard, which configures the whole instance for every
tenant. The `auto|password|off` switch added for #870 left the default
at `auto`, so the advisory never held on a default install.

The probe is now `x:Tenant/query`, which needs `sysTenantQuery`: by
default only the server-wide superuser holds it.
2026-09-26 14:19:58 +02:00
Linus Rath dd796d7325 fix: serve WOPI file downloads as inert attachments
GetFile answered on the webmail origin with the Content-Type stored in
the WOPI token, which for a mail attachment comes straight from the
launch request and for a Files node is a user-settable property. No
CSP, nosniff or disposition was added, since `/api/*` skips the proxy.
A user could mint a token for their own blob typed `text/html` and have
a victim open the GetFile URL, running script in the webmail origin.

GetFile now always sends `application/octet-stream` with
`Content-Disposition: attachment`, `nosniff` and a sandboxing CSP, and
refuses browser loads (any `Sec-Fetch-Dest` other than `empty`). The
editor fetches it server-to-server, so it is unaffected.
2026-09-26 14:16:42 +02:00