Commit Graph
1970 Commits
Author SHA1 Message Date
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 8b3e29fe75 fix: sign out every account whose client is gone instead of leaving it resumable
Signing out when the next registered account had no live client (its
restore failed at boot) fell back to a full logout, but only cleared
the signed-out account's slot and removed just that next account. The
other account's remembered session survived, and on the next visit the
orphan-cookie adoption in `checkAuth` signed the browser back in as
that account and read its password back.

The fallback now drops every remaining account and clears each of
their slots together with the signed-out one. The full logout also
removes the persisted `account-registry` instead of a key that never
existed (`account-storage`).
2026-09-26 15:03:54 +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 d13db4c756 test: give the conversation view remote-block test room to import 2026-09-26 14:51: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 c20527cc29 fix: block remote content in the .eml attachment preview
An attached `.eml` was sanitized and shown in a locked sandbox iframe,
but its remote images still loaded, with no CSP, whatever the user's
remote-content policy said. The preview now uses the viewer's
`sanitizeEmailBodyForIframe` and the strict iframe CSP unless the policy
is `allow`, and sends no referrer.
2026-09-26 14:50:22 +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
Linus Rath 012fe6edad fix: stop a crafted winmail.dat from freezing the tab
A MAPI property in a TNEF attachment declares how many values follow.
The parser looped that many times even when a value could not be read
and nothing was consumed, so a 31-byte winmail.dat claiming 0xFFFFFFFF
values froze the tab for about 13 seconds on every open, longer with
more such properties. The count is now clamped to what the block can
hold and the loop stops as soon as a value consumes no bytes.
2026-09-26 14:15:10 +02:00
Linus Rath 8f7124c878 fix: stop a sender from faking a DMARC or DKIM pass in the security badges
The Authentication-Results parser matched `dmarc=` and `dkim=` anywhere
in all headers joined together. A sender who used `dmarc=pass@...` as
the envelope address got a green DMARC pass for a message that failed,
because the server echoes that address in the SPF clause before its
real `dmarc=fail`. A sender could also add their own
Authentication-Results header to an unsigned message and get a DKIM and
DMARC pass. Calendar invites from such a message were shown as trusted.

The parser now splits each header on `;` outside quoted strings and
comments and reads the method only at the start of a part. DKIM, DMARC
and iprev come from the topmost header, which the receiving server
wrote; lower headers can only escalate SPF to a failure.
2026-09-26 14:13:33 +02:00
Linus Rath 9b9b4f7ef1 fix: reject cross-origin writes to the JMAP passthrough and other cookie-authed routes (GHSA-9mvj-98f5-9q6g)
The origin gate in the proxy only covered `/api/auth/*`. The JMAP
passthrough, `/api/settings`, `/api/wopi/launch`,
`/api/plugin-approval-status`, `/api/fetch-ical`, `/api/translate`,
`/api/caldav/discover`, `/api/calendar-agenda` and `/api/webdav` all act
with the session cookie and had no origin check. A page on a same-site
sibling origin receives SameSite=Lax cookies, so it could run arbitrary
JMAP as the signed-in user, including sharing mailboxes to a third
account.

The proxy now gates every `/api/` route except the WOPI file endpoints
the office editor calls server-to-server, and each of these handlers
checks again. The proxy also decides on the decoded path, so an escaped
spelling such as `/api/%61uth/session` no longer skips the gate.
2026-09-26 14:08:44 +02:00
Linus Rath 11eb0bfb16 fix: keep the Stalwart master password out of impersonated sessions
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.
2026-09-26 14:05:05 +02:00
Linus Rath 5d017fd2b1 fix: block loopback, CGNAT and IPv6 transition ranges in the SSRF guard
The URL guard behind fetch-ical, OAuth discovery, branding and the
server-side JMAP fetches blocked only the single address 127.0.0.1 plus
the RFC 1918, link-local and ULA subnets. Any logged-in user could make
the server fetch 127.0.0.2, 100.64.0.0/10, 198.18.0.0/15, multicast,
reserved space or a NAT64/6to4/Teredo address that wraps an internal
IPv4 address.

The telemetry guard already rejected all of these (GHSA-m7j8), so its
range check moves to `lib/security/ip-ranges.ts` and both guards use it.
`guardedLookup` inherits the wider list, so the connect-time rebinding
check covers the new ranges too.
2026-09-26 14:00:01 +02:00
Linus Rath 3668618c2d fix: translate the themes settings panel 2026-09-26 12:54:30 +02:00
Linus Rath 6d54b2e851 Merge branch 'main' of https://github.com/bulwarkmail/webmail 2026-09-26 12:41:47 +02:00
Linus Rath 8c8ed26cd1 Merge pull request #1106 from dulinux/i18n/update-pt-br-translations
I18n/update pt br translations
2026-09-26 12:41:42 +02:00
Linus Rath ff8b192508 fix: keep a folder from switching back to the unified inbox while it loads #1102 2026-09-26 12:41:13 +02:00
Linus Rath aea9edc03b Merge pull request #1103 from guisea/fix/folder-view-unified-blend
fix(email-store): stop late unified/cross-view fetches from stomping folder selection
2026-09-26 12:34:52 +02:00
Linus Rath 264b089e8d chore: keep gitignored local-data out of typecheck and lint 2026-09-26 12:34:06 +02:00
Linus Rath daf67850d2 fix: reply from the receiving account's identity in the unified inbox #1104 2026-09-26 12:31:29 +02:00
Luis Felipe Marzagao 4645bcd5c0 fix(test): Provide localStorage stub for vitest on jsdom 28 2026-09-25 23:34:37 -03:00
Luis Felipe Marzagao 8162cfd9bf fix: Add missing "Themes" translation to all locales 2026-09-25 23:34:08 -03:00
Luis Felipe Marzagao 1f211d175a fix: Add mising translation for “Themes” in settings tab 2026-09-25 22:21:05 -03:00
Luis Felipe Marzagao d17130f409 fix: Update the Portuguese translations in the common.json file 2026-09-25 22:15:49 -03:00
Linus Rath 70be66a4a0 Merge pull request #1105 from dulinux/i18n/dark-light-mode
fix: update dark/light mode titles and add translations for multiple languages
2026-09-25 10:51:33 +02:00
Luis Felipe Marzagao aef8ed2b9e fix: update dark/light mode titles and add translations for multiple languages 2026-09-24 23:07:15 -03:00
Aaron Guise c08375974a fix(email-store): stop late unified/cross-view fetches from stomping folder selection 🐛
fetchUnifiedEmails and fetchCrossView set isUnifiedView:true synchronously
then await a network fetch with no check on completion that the view is
still current. If one resolved after the user had since selected a real
folder, it re-set isUnifiedView back to true while selectedMailbox stayed
on the folder - so a push-driven refreshCurrentMailbox (e.g. after
deleting a message) took the unified branch and blended in Inbox-tagged
messages instead of refreshing the folder.

Add a viewToken bumped by selectMailbox/selectKeyword/exitUnifiedView;
fetchUnifiedEmails/fetchCrossView capture it before fetching and discard
their result if it no longer matches on return. Also make selectMailbox/
selectKeyword defensively reset isUnifiedView/unifiedRole/crossView.

Upstream: bulwarkmail/webmail#1102
2026-09-25 07:30:55 +12:00
Linus Rath 3823d2da17 fix: sign in and show addresses on internationalized domains #1100 2026-09-24 21:15:22 +02:00
Linus Rath e33ab899b0 fix: leave out the id token when sign-in cookies would overflow nginx's header buffer #1096 2026-09-24 18:23:11 +02:00
Linus Rath 4d11cbf60b fix: keep the inbox rendering when a message has an unparsable date #1099 2026-09-24 17:47:33 +02:00
Linus Rath e752355311 chore: release 1.11.0 1.11.0 2026-09-23 21:18:21 +02:00
Linus Rath 84a639f5d4 fix: keep bringing up unread mail past the first page in unread-first order 2026-09-23 21:01:48 +02:00
Linus Rath f97218375e fix: end the identity provider session on sign-out #905 2026-09-23 20:44:46 +02:00
Linus Rath 1c00cf75cb fix: use the from override as envelope sender where the server allows it #1009 2026-09-23 19:58:26 +02:00
Linus Rath 198c6d41e0 fix: wrap text in fixed-width tables to the screen on iOS #1020 2026-09-23 19:41:44 +02:00