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.
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.
`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.
`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.
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.
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.
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.
The composer took its client, identities and active account live from
the stores. Switching accounts with a draft open moved it: autosave
created the draft in the other account, and replacing the previous
save sent that account a delete for the old draft's id, which there
names an unrelated message. The composer now stays on the account it
was opened in; the other account's identities and client are ignored
until it is closed.
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.
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.
Clicking account B and then C before the first switch finished ran
both switches side by side, each holding the account that was active
when it started. The later one then snapshotted B's live stores under
A's key, so switching back to A restored B's identities (and mail) and
never re-fetched them: replies went out with B's From address.
Each switch now takes a generation and stops at its next await once a
newer switch has started, the outgoing account is read when the
snapshot is taken, and stores another switch already cleared are not
snapshotted over the cached state.
Persisted calendar subscriptions were matched to an account by the bare
JMAP account id, or not at all for subscriptions saved by older builds,
and to their calendar by a bare id. Stalwart hands out the same small
ids on every server, so refreshing a subscription through another login
diffed away the events of an unrelated calendar there, and "Remove"
destroyed that calendar. The secret feed URLs also stayed in
localStorage after signing out.
- new subscriptions record their owner (server plus login name), and
refresh, rename and remove act only through that login
- a subscription without an owner is adopted only when its calendar id
and name both exist in the account; otherwise it is left alone and
removing it only drops the local record
- signing out forgets that login's subscriptions, and a full sign-out
clears them all
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.
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.
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.
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.
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
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.
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.
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`).
`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.
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.
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.
`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.
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.
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.
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`.
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.
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()`
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.