DOMPurify URI-tests srcset as a single string, so an allowed first
candidate waved every later one through - an SVG data: URI could ride
in behind a raster candidate on <img srcset> and <picture><source>.
Check every data: occurrence against the raster allowlist and drop the
whole attribute on one disallowed hit, including the external blocker's
data-blocked-srcset stash, which shares the hook pass with no ordering
guarantee. The token is captured through the metadata-ending comma so
the plain (non-base64) raster form keeps the same verdict as in src.
next-intl has no "../" relative-namespace escape. A key like
t('../context_menu.not_spam') is not resolved relative to anything -
it is concatenated literally into the lookup key and always misses,
throwing MISSING_MESSAGE. email-list.tsx, about-data-settings.tsx,
and account-settings.tsx all used this pattern to reach keys outside
their own translation namespace.
Fixed by calling useTranslations() again for each additional
namespace needed, which is the supported way to reach sibling
namespaces, instead of trying to walk out of the current one.
The translations test already verifies that every key referenced in
source resolves in the en locale, but its extractor skipped any key
starting with a dot, which is exactly the shape of the broken calls,
so this class of bug never failed CI. Since no legitimate key can
start with a dot, the exclusion is removed. With it gone the test
reports all thirteen call sites fixed here when run against the old
code, and passes with the fix, so a reintroduced relative path fails
CI with the offending file listed.
contactUsing(), calendarUsing(), and fileUsing() checked the base
"principals" capability but then declared the separate
"principals:owner" sub-capability (RFC 9670) in the request's "using"
array. RFC 8620 section 3.3 requires a server to reject the whole
request if it declares a capability the server does not advertise, so
a server that supports base principals without the owner
sub-capability rejects every one of these requests with
unknownCapability.
Fixed to check the specific sub-capability being declared instead of
assuming it from the broader one. The calendar agenda route had the
same mismatch and is fixed the same way. Note that RFC 9670 section
1.5.2 places principals:owner in accountCapabilities rather than the
session capabilities, so on compliant servers the sub-capability is
simply never declared, which is the intent. Nothing is lost by
omitting it.
The file share test asserted the old behavior by expecting the owner
capability to be declared against a server that advertises only base
principals. It now asserts the capability is omitted in that case and
declared only when the server advertises it. The same pair of
assertions is added for the contacts, calendars, and agenda route
request builders, so each site this commit touches is covered against
regression.
RFC 8620 does not require Session object URLs to be absolute, but
rewriteSessionUrl() called new URL(url) with no base, which throws on a
relative apiUrl or downloadUrl. The throw was silently caught and the
raw relative string returned, so requests resolved against the app's
own origin instead of the JMAP server.
Resolving with the URL parser and reading back href or pathname is not
an option: downloadUrl, uploadUrl and eventSourceUrl are RFC 6570
level 1 URI Templates (RFC 8620 section 2) whose literal {accountId},
{blobId}, {name} and {type} placeholders would be percent-encoded to
%7B/%7D, silently defeating the string replace() expansion at every
call site. Instead, anchor the path, query and fragment of the original
string at the server origin, which also covers network-path references
(//host/path) and does not fold a path prefix on the configured server
URL into relative resolution.
An empty URL is returned untouched: rewriting it into the bare server
origin would make it truthy and defeat the "not available" guards on
apiUrl and downloadUrl downstream.
The rights-first resolver already made EXISTING events in subscription /
read-only calendars read-only, but the create path still offered them:
event-modal's calendar dropdown listed every calendar. iCal subscriptions are
real Stalwart calendars (client-side feed written into a created calendar) with
writable myRights, so they appeared as selectable create destinations.
- canCreateEventsIn(calendar, isSubscriptionCalendar): writable AND not a
subscription. Exported from lib/calendar-editability.
- event-modal: the calendar picker now lists only creatable calendars (plus the
event's own calendar when editing), and new events never default into a
subscription / read-only calendar.
+4 unit tests. tsc + eslint clean; calendar-editability suite 30 green.
ALLOWED_URI_REGEXP excludes image/svg+xml on purpose - DOMPurify cannot
inspect the bytes inside a data: URI, so an SVG payload can carry
<script>/<foreignObject> the surrounding sanitizer never sees. That
exclusion never took effect on <img>, <video>, <audio>, <source> and
<track>: DOMPurify's attribute check short-circuits on DATA_URI_TAGS
before ALLOWED_URI_REGEXP is consulted, and that set can only be extended
via ADD_DATA_URI_TAGS, never trimmed. Any data: URI reached those tags,
including image/svg+xml and text/html; <a href> behaved as documented.
Not a live script vector - browsers render SVG-in-<img> in secure static
mode - but the sanitizer promised something it did not enforce, and the
print window renders sanitized bodies without the iframe sandbox and CSP
that otherwise back it up.
restrictDataUriResourcesOnNode re-applies the raster allowlist on those
tags, wired in through the existing afterSanitizeAttributes hooks. The
blocked-external-image placeholder becomes a 1x1 GIF: it is set in the
same hook pass, so an SVG placeholder would depend on hook order.
The three viewer call sites that hand-rolled
DOMPurify.sanitize(html, EMAIL_IFRAME_SANITIZE_CONFIG) now use
sanitizeEmailHtmlForIframe, which is the same thing and picks up the
guard automatically.
Event editability was inferred from identity (isOrigin + organizer match)
and never from the calendar's write rights. That opened events in read-only
shared / iCal-subscription calendars for editing, and once the address list
was widened with account aliases any alias-organized event became editable -
even in calendars the user cannot write.
- lib/calendar-editability.ts: getEventEditability() -> editable | rsvp-only
| read-only, gating on the calendar's myRights (mayWriteAll / mayWriteOwn /
mayRSVP) first, then organizer/participant identity. Subscriptions are
read-only. Aliases now only refine the write-own / RSVP branches, so they
can no longer over-open events (Stalwart: organizer identity does not
confer calendar write; the ACL does).
- collectUserCalendarAddresses(): merge identities + account aliases so an
alias organizer is recognised as the user's own; calendar-app fetches the
principal aliases and passes isSubscriptionCalendar through.
- event-detail-popover / event-modal: derive Edit/Delete/RSVP/read-only from
the resolver (three render states); read-only is new - no edit, no RSVP.
Tests: calendar-editability (26 cases over rights x role x subscription x
origin, incl. the alias-in-read-only regression) + calendar-participants.
tsc + eslint clean.
Review follow-up to #751.
The offload path could not actually run: onBeforeBlobUpload goes through
HookBus.transform, which capped every handler at the 5s default. A handler
that pushes the file to an external store blows past that, and on timeout
the error is swallowed, the original file id is kept, and the composer
attaches the big file anyway - silently - while three such timeouts in a
minute auto-disable the plugin. Buses can now carry their own per-handler
budget and this one gets 120s. The same ceiling existed inside the sandbox,
where http.post/http.fetch were bounded by the 30s default API timeout;
those two are transfers rather than round-trips and now get 120s as well.
PRIVILEGED_ONLY_METHODS listed 'upfiles.set', which matches no dispatched
method - the dispatcher calls it 'upfiles.save'. So the write was never
tier-gated despite the comment saying it was, and an untrusted plugin with
email:blob-write could replace the bytes of a file the user was about to
send. Fixed the name. Relaxing the read while that stayed open was the
wrong order.
Moving upfiles.get to email:blob-read is kept, but it is a wider grant than
the PR described: onBeforeBlobUpload hands the file id to every registered
handler, so the id is not a secret and any untrusted plugin holding
email:blob-read can read every file the user attaches. The consent copy now
says that; email:blob-write's copy was describing the read and now describes
the replace it actually gates.
Also in the composer offload path:
- sanitize the handler's html before it reaches the document and the sent
message, and insert it through the editor at the caret rather than
concatenating it after the signature and quoted block
- honour an abort that lands while the handler is offloading
- delete the right staged file when the handler re-saved it under a new id
via upfiles.save, which otherwise stranded a copy in IndexedDB forever
upfiles.get is a read, but sat behind the email:blob-write permission and
the privileged tier, which requires crypto:full plus a signed managed
bundle. That put a simple attachment-offload plugin in the same trust
bucket as crypto plugins that read stored message bytes.
The method only returns a file the user just attached in this session,
looked up by the unguessable UUID the composer itself passes to the
onBeforeBlobUpload hook. It cannot reach server-side message blobs; those
stay behind jmap.fetchBlob on the privileged tier. So gate it behind
email:blob-read (consent copy already covers attachment bytes) and drop
the tier requirement.
Note: the privileged set also lists 'upfiles.set', but the dispatched
method is named 'upfiles.save', so that entry never matched; left as-is
here, flagged in the PR.
api.http.post(path, body, options) now accepts a Blob/File body and sends
it as-is to the same-origin route instead of JSON.stringify-ing it into
'{}'. The plugin may only supply Content-Type and X-Plugin-* metadata
headers; anything else is refused, and the host attaches its credential
headers after the plugin's, so they can never be overridden. Non-Blob
bodies keep the stock JSON behaviour. The sandbox facade previously
dropped the third argument, so the options never reached the host.
A handler can now return an ExternalAttachmentResult ({externalAttachment:
true, html, text}) instead of a file id. The composer then deletes the
staged file, removes the attachment and appends the replacement content
to the body, so a plugin can push a large file to an external store and
leave a download link behind. Returning a string (or nothing) keeps the
stock upload path byte-for-byte.
Also adds plainTextMode to the upload callback deps, which the new branch
reads to pick between the html and text replacement.
Clicking an address in the app's own chrome - the recipient card, the
contact sidebar, the contact details - handed a mailto: URL to the OS
mail handler. In a webmail client that goes nowhere: the browser asks
which application should open the link, and the message is composed
somewhere else, if at all. Only users who registered Bulwark itself as
their system mailto handler got a usable result, and even then via a
detour through the OS into a new tab.
A new MailtoLink component sends the click to the built-in composer
instead. It stays a real anchor with an href, so "Copy email address",
middle click and assistive technology keep working; only the plain
left click is redirected, and only when a listener takes it - otherwise
the browser's own behaviour still applies. Modified clicks are left
alone.
The request reaches the mail page through a window event, the way
`bulwark:rate-limit-blocked` already does, rather than threading a
callback through three component levels and five call sites. It goes to
openMailtoDraft rather than the protocol path: an in-app click carries
its own context - the account being read - so the account picker that an
external mailto: needs would only get in the way here.
Message content is deliberately untouched. Plain text bodies render into
the main document rather than the sandboxed frame and their sanitizer
permits mailto:, so a document-wide interceptor would have swept up
untrusted links too; a component cannot be reached from message HTML at
all.
Opening a message with a calendar invitation printed the whole parsed
event to the console on every render - title, description, attendees and
location included - through a leftover `[PARSE DEBUG]` console.log.
The surrounding file already logs via debug.log('calendar', ...) in 22
places, which honours the debugMode setting and its category filters, so
this one line was the outlier. It also ran JSON.stringify with
indentation unconditionally, even though nothing was listening.
Passing the object to debug.log instead leaves the formatting to the
logger, so nothing is serialised unless the calendar debug category is
actually switched on.