Corrects an architecture call I got wrong earlier in the session. I had said
CaProvider would live in the plugin. It cannot, for two independent reasons:
1. EJBCA's REST API authenticates with a CLIENT CERTIFICATE. A browser
cannot present one from fetch, and must not hold one anyway - the RA
credential is the authority to mint certificates, so putting it
anywhere script-reachable turns any XSS into a certificate factory.
2. Only the server can answer "does this person actually own this
address?" A browser asserting its own identity to a CA is not
authentication.
So: the plugin generates the keypair and CSR (private key never leaves the
device), and this layer decides which addresses the certificate may assert.
api.http.post is the bridge, and the fact that it forwards the user's JMAP
auth header is what makes the identity check possible at all.
The design decision worth calling out: the CSR is NOT trusted for identity,
and the route does not parse it to police what it asks for. It doesn't need
to. The route supplies the subject and the rfc822Name SAN itself from
addresses it verified independently; the CSR contributes only a public key
and proof of possession. A CSR hand-crafted to claim the CEO's address does
not have to be detected and rejected - the extension it asks for simply
never reaches the certificate.
That property depends entirely on EJBCA ignoring CSR-supplied subjects and
extensions, which is three checkboxes in the certificate profile. Added to
the runbook as the most important line in it, with a concrete verification
using a hostile CSR - because with those overrides ON, the enrolment route
still looks correct in review while issuing certificates for any address.
Identity comes from Stalwart via Identity/get, not from the auth cookie's
username. The cookie is encrypted and server-minted so it cannot be forged,
but it is still the wrong authority: the right answer to "may this person
have a signing certificate for this address" is held by the mail server
that already decides "may this person send from this address". Anything else
invents a second, weaker answer to a settled question.
It also handles two cases the cookie cannot:
- an alias the account legitimately sends as, which belongs ON the
certificate and which the cookie does not know about
- an administrative principal with no mailbox, which must get NOTHING.
Not hypothetical: admin@sandbox.vnc.de authenticates successfully and
has no mail session, so trusting the cookie would have issued it a
certificate for an address it cannot send from.
Wildcard identities (*@domain) are filtered out. Stalwart can legitimately
report one for an account allowed to send as anything in a domain, but it is
a capability, not an address - and a rfc822Name SAN of *@vnc.de is either
rejected by clients or, worse, honoured.
Other deliberate choices:
- Pins EJBCA's own chain for the mTLS connection instead of the public root
store. EJBCA serves a self-signed cert on that listener by design, and
rejectUnauthorized:false would be worse than either option - it would let
anything on the cluster network impersonate the CA and harvest CSRs.
- CA error bodies are logged server-side and replaced with generic messages.
An enrolment endpoint should not double as a way to probe CA config.
- DN component values are RFC 4514 escaped. The CN comes from a display
name; an unescaped comma or plus would inject additional RDNs.
- getCaProvider() returns null rather than throwing when unconfigured, so
the route 503s and nothing else is affected. Enrolment is opt-in; a
missing CA secret must not stop anyone reading their mail.
- revoke() is documented as needing to work when enrolment is broken. It is
the incident-response path, and a design that can only revoke through the
same path that issues is one outage from being unable to answer a key
compromise.
Typechecks clean. Not yet exercised against a live CA - the browser half of
C-08 (keypair + CSR generation in the plugin) and a real EJBCA to enrol
against are both still outstanding, so nothing here has issued a
certificate yet.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The overrideWarnings escape hatch added alongside the bundle scan was
API-only: an admin uploading a crypto plugin through the web form hit a
400 with canOverride and had no way to act on it, which left S/MIME and
PGP bundles uninstallable through the UI.
Hold the rejected file client-side and show the findings — pattern per
file — with "Install anyway" and "Cancel". Proceeding re-posts the same
file with overrideWarnings, so the decision stays explicit and lands in
the audit log. The route now echoes accepted findings back on success so
the confirmation says how many were waved through rather than reporting a
bare install.
Also replaces a dead `data.warnings` read with the live `findings` field;
the route never returned `warnings` on success, so that branch never ran.
Completes B-01.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two problems with the upload scanner, pulling in opposite directions.
It only scanned the entrypoint, so a bundle with `eval()` in a second
file passed outright — verified against a synthetic bundle whose
vendor/openpgp.js tripped three patterns while index.js stayed clean.
At the same time, a hard 400 on eval()/new Function()/innerHTML= makes
every crypto plugin uninstallable: minified openpgp.js and pkijs
legitimately contain all three. That blocks S/MIME and PGP entirely.
Scan every .js/.mjs in the bundle and return structured findings
({file, patterns[]}) plus canOverride, so the admin can see exactly what
tripped and where. An explicit overrideWarnings=true proceeds and writes
a plugin.install.scan_override audit entry recording which patterns in
which files were accepted — not merely that an override happened.
This route is already admin-authenticated, so the scan is defence in
depth against an accidental or compromised upload, not a trust boundary.
Treating it as the latter is what made crypto plugins uninstallable.
Also log the B-04 and B-01 divergences in vnc/VNC-CHANGES.md.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
If you have a large number of tags, getTagCounts would not be able to get the
unread counts because it did not respect maxCallsInRequest, even though the
value was actually read out, it was just ignored. There are also other places
where the limits were not respected.
Batching is now generalized in a helper that also takes maxObjectsInSet, which
also was ignored, into account and is applied to all functions.
In addition, the dev mock now also advertises and enforces the limits, so these
issues can get picked up during development.
Possible closes#699
Possibly closes#399
After a master-user impersonation handoff (GET /api/auth/impersonate) the server
swaps the slot-0 session cookie but the client's persisted account registry
(account-registry / auth-storage in localStorage) still lists the previous
account, so the top-left account chip keeps showing the old mailbox until a
manual sign-out. Redirect impersonation to /?impersonated=1 and add a headless
ImpersonationReconciler that drops the stale persisted account/auth state (and
server-derived caches) then reloads to a clean URL, so the app rehydrates empty
and re-derives the single account from the fresh session. Cookies untouched, so
the just-granted session survives. Runs exactly once.
Reported downstream: shukiv/jabali-panel#646.
Any transient failure used to end the session: the token route deleted
the refresh cookies on every non-OK answer from the OAuth endpoint,
refreshAccessToken logged out on any non-OK status or network error,
and the startup restore evicted the account and deleted its session
cookie. A server restart, a proxy hiccup, a Wi-Fi switch or a laptop
waking before the network is back all kicked the user out despite
"stay signed in".
Failures are now classified. Only a definitive rejection (400/401/403
from the OAuth endpoint, 401 from the token route) tears the session
down and deletes cookies, exactly as before. Network errors and 5xx
keep the session: the token refresh re-arms itself and retries every
~30 seconds until the server is back, and the startup restore keeps
the account, marked unreachable - the same treatment the rate-limit
carve-out (#104) already applies.
Token validity stays entirely server-enforced: the first definitive
401 after an outage still logs out as before.
Login header customization for white-label deployments, all defaults
preserve current behaviour:
- LOGIN_LOGO_MAX_HEIGHT / LOGIN_LOGO_MAX_WIDTH (any CSS length): the logo
box is otherwise a fixed 64x64 (w-16/h-16), which fits a wide wordmark to
~13px tall. When either is set, the fixed box is dropped and the logo
renders at the configured size.
- LOGIN_SHOW_HEADING / LOGIN_SHOW_SUBTITLE (default true): hide the
{appName} heading and/or the subtitle when the logo already reads as the
brand (e.g. a wordmark) and they'd be redundant.
Applied to the standard login header; wired through the existing config
registry (CONFIG_ENV_MAP) -> /api/config -> useConfig.
Refs #519.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The service worker hard-coded the notification icon and badge to the bundled /icon-192x192.png, so push notifications always showed the default Bulwark logo even when an admin had configured a custom PWA/favicon icon (which the manifest already honors via /api/pwa-icon).
Point both notifications at /api/pwa-icon/192, and make that endpoint fall back to the bundled default icon instead of returning 404 when no custom icon is set - so it always returns an app icon and the service worker (which can't run the custom-vs-default check itself) has a single stable URL.
Two opt-out branding/login flags, both default true (no behaviour change
for existing deployments):
- LOGIN_SHOW_TOTP=false hides the manual "I have a 2FA code" toggle on the
login form. Deployments that delegate auth to an external directory
(LDAP/OIDC) where 2FA lives in the IdP have no server-side TOTP, so the
toggle only ever leads to a failed login. Server-required TOTP
(totp_required, which auto-shows the field) is unaffected.
- LOGIN_SHOW_VERSION=false hides the build version in the login footer, so
the exact version isn't disclosed to unauthenticated visitors.
Wired through the existing config registry (CONFIG_ENV_MAP) → /api/config →
useConfig, matching the surrounding LOGIN_* options.
Refs #519.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Cleans up the lint warnings the pre-commit hook surfaces, without any
behavioral change:
- Remove unused imports/vars/destructured props (parseISO, useEffect,
format, durMin, roles, daysInYear, ALLOWED_PLUGIN_FILES, continuesBefore,
isPushConnected, isSelected) and the now-unused parseDuration import.
- Drop three stale `// eslint-disable-next-line no-undef` directives that
no longer suppress anything (browser-navigation, smime/crypto-engine).
- recurrence-expansion.test.ts: replace 39 `as any` casts with a cast-only
`rule()` helper for partial recurrence-rule fixtures, typed access to
utcStart/utcEnd (now on CalendarEvent), and the source's
`Partial<CalendarEvent> & { excluded?: boolean }` for the excluded
override. No defaults are injected, so the expansion logic sees the same
partial rules as before (35 tests still green).
Remaining: 7 react-hooks/exhaustive-deps warnings are left as-is — adding
the missing deps changes effect/memo timing and needs per-hook review, not
a mechanical fix. tsc --noEmit clean; eslint 0 errors / 7 warnings.
Lets a per-brand authorize host front a single canonical issuer, so the
IdP token's `iss` stays constant for downstream validation while login
branding varies per domain. Discovery, token exchange and refresh keep
using OAUTH_ISSUER_URL.
Give email-002 ("Project Update - Q1 Review") a sender and CC with
"Lastname, Firstname" display names so Reply/Reply-All in dev mode
exercises the comma-in-name recipient case end to end.
/api/favicon returned 404 in three paths (negative cache hit, non-200
upstream, sub-10-byte body), and since the avatar loads it as <img src>,
the browser logged a red 404 for every sender domain without a public
favicon - dozens per inbox view. Now it returns HTTP 200 with a 1x1
transparent PNG and an X-Bulwark-Favicon: missing header. Avatar.tsx detects
the sentinel via naturalWidth <= 1 in onLoad and falls back to initials, so
behaviour is visually identical without the console noise.
The plugin runtime received the active locale (init payload + 'locale-change')
and plugins could declare a `locales` map, but none of it was usable: the
locales never reached the runtime, and buildPluginApi exposed no i18n. So
plugin code calling pluginApi.i18n.t(...) (as the External Link Warning plugin
does) always got undefined and fell back to English.
Thread plugin locales end to end and surface an i18n API:
- ServerPlugin gains `locales`; the upload route persists manifest.locales
(alongside configSchema/settingsSchema), and /api/plugins surfaces it to the
client so it flows registry -> client -> sandbox host-bridge -> runtime.
- runtime sets __PLUGIN_LOCALE__ at init (not only on later 'locale-change')
and buildPluginApi exposes `i18n.locale` + `i18n.t(key, vars)` resolving
against the plugin's declared locales (manifest.locales) with English/key
fallback and {placeholder} interpolation.
Lets any sandboxed plugin localize its strings from its manifest.
Admins can upload custom mobile/desktop screenshots shown in the browser's
PWA install dialog, replacing the hardcoded Bulwark ones. Two new config keys
(pwaScreenshotMobileUrl/DesktopUrl), upload widgets in the admin Branding tab,
a sharp-based /api/pwa-screenshot/[variant] resize route, and manifest.ts picks
the custom screenshots when configured.
Like the other branding fields, screenshots are per-domain: they are
BRANDING_OVERRIDE_KEYS, the manifest and the /api/pwa-screenshot route resolve
them from the request host (domain override -> global -> Bulwark default), and
the admin Branding tab + upload/delete route handle them in a per-domain scope,
mirroring pwaIconUrl/faviconUrl.
The dev mock's Identity/set discarded its payload and Identity/get always
returned a static list, so saved identities never round-tripped in local
development. Persist create (with mayDelete: true), update, and destroy in
place, mirroring handleMailboxSet, so signature edits stick when testing
without a real JMAP server.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>