The admin page offered "User Plugin Uploads" and "Require Admin
Approval", but nothing in Settings used them: there was no upload, no
uninstall and no way to ask for approval, and the admin had no list of
requests. The approval code that existed pinned a hash of the code only
and showed the admin a manifest the browser sent, never the code.
Now:
- Settings > Plugins has an install button (when uploads are allowed),
a remove button for the user's own plugins, and lists in plain
language what each plugin may do, high-risk items first
- an install sends the ZIP to the server, which reads the manifest and
code itself and pins the request to a hash of both
- the admin Plugins tab lists requests with the code and approves or
denies them; a user may have 10 open requests
- a user plugin is checked again on every start, so a revoked approval
or turning user plugins off takes effect
- the consent dialog and the plugin settings are translated, use the
theme colours, and show the sites a plugin may send data to
The Settings upload, the admin upload, the marketplace install and the
marketplace preview each parsed the ZIP and manifest with their own copy
of the code, and the copies had drifted. A `permissions` string skipped
validation and was then matched by substring, a small ZIP could inflate
to hundreds of megabytes on the server, the Settings upload dropped
`locales` and took origin lists unchecked, and settings schemas were
never checked at all.
All of them now go through `lib/extension-package.ts`:
- every file is inflated through a byte cap, with a limit on entries
- one manifest check: types, permissions, entrypoint, version,
minAppVersion, settingsSchema, configSchema, locales and origin lists
- only checked fields are stored
Three files carried a literal NUL byte inside a string: the admin
check's cache key in `app/api/admin/auth/route.ts`, the global search
hit key in `lib/global-search/types.ts` and two TNEF fixtures in
`lib/__tests__/tnef.test.ts`. Git treats any file containing a NUL as
binary, so it showed no diffs for them, never normalised their line
endings, and reported every pull request that touched the admin route
as a conflict even when a text merge was clean.
The escape produces the same string at runtime. The admin route was
stored with CRLF while git saw it as binary; it is now stored with LF
like the rest of the tree, so its diff covers the whole file (the only
content change is on line 70).
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.
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()`
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.
The setup wizard and the admin sign-in set their session cookies by two
different rules. The wizard matches Secure to the request - X-Forwarded-
Proto from the proxy, else the URL scheme - because browsers drop Secure
cookies on plain HTTP and an unconditional Secure broke setup over HTTP.
The admin cookie kept `secure: NODE_ENV === 'production'`, so on the same
HTTP deployment the wizard worked and admin sign-in failed silently: the
cookie was set, the browser threw it away, every call came back 401.
Both now share one helper, lib/security/request-protocol.ts, and the admin
route passes its request in. Behind a TLS proxy that forwards the proto
nothing changes: the cookie is Secure as before. Callers that pass no
request keep the production default.
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.
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.