v3.3.7
3.3.7 is a security release. It closes a stored XSS in the pad and timeslider renderer (GHSA-4mx2-rqx5-2pp6).
- Renderer — attribute-pool values can no longer forge class tokens (GHSA-4mx2-rqx5-2pp6, #8302).
linestylefilterappended thestartandlistline-attribute values verbatim to the space-delimited class string thatdomlineparses back, anddomlineemits anytag:token in that string as a raw element. Astartvalue containing a space, which can be planted through a crafted changeset or.etherpadimport, therefore smuggled in a token such astag:img/src=x/onerror=...and rendered a live element with a live event handler for every viewer of the pad and its timeslider. This is the same function as GHSA-f7h5-v9hm-548j but a different token.startis now emitted only when it is an integer andlistonly when it is a plain list type, anddomlineaccepts only a bare element name (letters, digits and hyphens) fortag:tokens, which also covers class strings contributed by plugins. Reported by @arpitjain099.
- Auth — an empty-string password is refused on both login paths (#8261). A
settings.usersentry configured as"password": ""authenticated anyone who submitted an empty password, on the OIDC interaction path and on HTTP Basic. Both already failed closed for a nullish password; an empty string slipped through because it is a string and compares equal to an empty submission. Only explicit misconfiguration produces it, so this is hardening rather than a vulnerability. Reported by Wenhao Wu (Southeast University) while verifying the fix for GHSA-62cj-9j72-mfrh. - Admin — the plugin catalog no longer offers deprecated or known-broken plugins (#8246). The "Available plugins" list was built straight from the plugin feed, so any package the feed knew about could be installed from the admin UI — including packages npm marks deprecated, packages the plugin registry itself could not get working against the current release, and
ep_adminpads2, which is archived upstream and takes over/admin/padswith a template whose scripts core no longer ships, hanging the admin page on "Loading…". Those are now filtered out of the catalog, the admin UI refuses to install one if a stale page asks for it anyway (pnpm run plugins i ep_<name>on the server still overrides), and an already-installed plugin in that state is flagged as deprecated in the Installed plugins list. The npm deprecation lookup is cached for 12 hours and fails open: if the registry cannot be reached the full catalog is still listed. Reported by @JohnMcLear.
v3.3.6
3.3.6 is a security and bug-fix release. It closes an OIDC login bypass for accounts configured without a password (GHSA-62cj-9j72-mfrh), and fixes a batch of reported defects across the installer, the admin settings editor, session transfer, the welcome screen, accessibility and plugin configuration.
- OIDC — refuse interactive logins for accounts without a password (GHSA-62cj-9j72-mfrh, #8247). The embedded OpenID Connect provider compared the submitted password against
String(user.password), so asettings.usersaccount with nopasswordproperty compared against the literal string"undefined", and one with"password": nullagainst"null". Submitting that literal logged the account in and issued a token carrying itsadminclaim; with the defaultauthenticationMethod: "sso"the HTTP API accepts that token. Accounts without a usable password occur in practice — the container image leavespasswordnull whenADMIN_PASSWORDis unset, and anep_hash_authentry replacespasswordwithhash. The sibling HTTP Basic path inwebaccess.tsalready failed closed here; the credential check now lives inverifyInteractiveLogin()and refuses any account without a real string password rather than coercing a missing secret to a literal. Hash-only entries are refused on this path too, since they authenticate through theauthenticatehook, which it does not consult. Reported by Wenhao Wu (Southeast University).
- PDF export honours
font-familywithout LibreOffice (#8245, #8249). The built-in PDF path used only pdfkit's Helvetica and Courier and ignoredfont-familyentirely, so any font applied by a plugin such asep_font_familywas lost — while HTML, ODT and DOCX all carried it. Font families are now mapped onto the PDF standard fonts by category (sans-serif to Helvetica, serif to Times, monospace to Courier), including the bold and italic variants, honouring declaration order and!important. Exact non-standard faces can be supplied by an operator through the newexportPdfFontssetting, which points a family at TTF/OTF files; no fonts are bundled. Every failure path degrades with a warning rather than failing the export, and pads with no font styling export exactly as before. Family names arrive from pad content, so they are normalised and matched against an allow-list and are never used as a file path. - Installer — the Node version check no longer fails under Windows PowerShell 5.1 (#8214, #8235).
bin/installer.ps1read the major version withnode -p 'process.versions.node.split(".")[0]'. Windows PowerShell 5.1 — the default shell on Windows 10 and 11, and a version the script declares support for — strips the double quotes when passing arguments to a program, so Node receivedsplit(.)[0]and threw aSyntaxError. The empty result became 0, and the installer rejected every Node version as too old. The version now comes fromnode --versionparsed in PowerShell, and an unparsable result reports that rather than claiming the version is too old. The Windows CI job now runs under both PowerShell 7 and Windows PowerShell 5.1. - Admin — settings form fields honour escape sequences (#8211, #8239). In the settings form view (raw mode was unaffected), string settings are edited in single-line inputs. Plain strings such as
defaultPadTextwere rendered with literal newlines, which the browser silently strips from a single-line input, and whatever the user typed was escaped a second time on save, so\nwas written as\\n. Environment-variable defaults such as${DEFAULT_PAD_TEXT:...}were shown escaped but escaped again on save. Both widgets now display values in escaped form and decode them before saving, so typingWelcome\n\ntest\nwrites the same bytes as editingsettings.jsonby hand. A half-typed escape is not saved: the field is marked invalid and reverts to the last saved value on blur. - Session transfer — preferences survive the transfer, and the cookie is no longer double-encoded (#8171, #8238). The transfer only handled the
prefsHttpcookie, but over HTTPS the pad stores its preferences inprefs, so nothing was sent and the receiving side wrote a cookie the destination never reads. The client also sent the cookie still percent-encoded andres.cookie()encoded it again, leaving a value the destination pad could not parse, so it silently fell back to defaults. The server now reads the preferences from the request's own cookies (accepting either name, with or without the cookie prefix), accepts only a JSON object, and writesprefsorprefsHttpaccording toreq.secure, encoded once. When there is nothing to transfer no cookie is written, so an existing destination preference set is no longer wiped. Author-token handling is unchanged. - Session transfer — the dialog describes what actually happens (#8173, #8236). The home-page dialog offered to copy a "link" that would move your "session". It copies a one-time code, valid once and for five minutes, that is pasted into the Receive session tab, and what moves is the author identity and preferences, not a sign-in session. The English wording of the existing strings now says so.
- Welcome screen — deleted pads leave the recent list (#8201, #8237). The Recent pads list is stored in the browser and nothing ever removed an entry, so a deleted pad stayed listed and opening it silently created a new, empty pad under the same name. Clients now drop the pad from the list when the server announces the deletion, which covers the creator's own Delete pad action, a deletion performed with the recovery token from another device, and any other tab open on the pad. Names stored URL-encoded by older versions are matched too. Pads deleted through the HTTP API or the admin interface still linger in browsers that had no tab open on them, since the list is per-browser.
- Accessibility — screen readers can move through a pad line by line (#7778, #8240). Every pad line is rendered as a plain
<div>with no role, which browsers expose as an anonymous generic node, so assistive technology saw one flattened run of text with no line boundaries and no way to step between lines or reach the links on a line. Plain lines now carryrole="paragraph"; lines that already contain a semantic block element, such as list items and headings fromep_headings2, keep their native semantics. The element itself is unchanged, so plugin selectors that targetdiv.ace-lineare unaffected. - Plugins —
settings.ep_<plugin>config blocks are reachable again fromrequire()(#8109, #8110). Plugins read their own configuration out of a top-levelep_*block insettings.jsonviarequire('ep_etherpad-lite/node/utils/Settings'). The CJS-compatibility shim inSettings.tsinstalled accessor properties onmodule.exportsfor the keys present on the settings object while that module was still evaluating — butep_*blocks are only merged in later, by thereloadSettings()call at the bottom of the same module. Every plugin config block was therefore invisible to therequire()path (the value was reachable only under.default), so plugins silently fell back to their built-in defaults. Forep_hash_auththat meanthash_dirreverted to/var/etherpad/users, every hash lookup failed, and admin login returned 401 with no usable diagnostic — the symptom that surfaced this. The shim is now re-run after each settings load. Reported by @mathewcsims and @tris-ots; an equivalent fix was also proposed by @AkprasadoP in #8113.
v3.3.5
3.3.5 is a bug-fix follow-up to 3.3.4. It fixes a startup crash on fresh installs when pnpm 12 (now pnpm's default release) is installed, and makes the built-in updater work on Windows.
- Fresh installs no longer crash at startup under pnpm 12 (#8232). On first boot (no
var/installed_plugins.json), Etherpad runspnpm lsto migrate plugins found innode_modules, passing--no-production. pnpm 12 renamed that flag to--no-prodand rejects the old name, so the server exited withError occurred while starting Etherpadbefore it bound a port. The call now uses--no-prod, which pnpm 10 and 11 also accept. Existing installs were not affected. The repository, release tooling, Docker image and snap also move to pnpm 12. - Updater — spawn pnpm through
cross-spawnso updates work on Windows (#8218). On Windowspnpmis a.cmdshim that plainchild_process.spawncan't run, so the built-in update actions failed there. Thanks to Kaan Çelebi.
v3.3.4
3.3.4 is a security release. It closes a stored XSS in the createDiffHTML API output (GHSA-6vx2-3gwr-958v).
- Neutralize author IDs and colors in HTML diff export (GHSA-6vx2-3gwr-958v).
getHTMLFromAtextplaced author colors inside a<style>block, and author IDs in both the CSS selector and a<span class>attribute, with no escaping. Anyone who can import a.etherpadfile (anonymous by default) could plant a craftedcolorIdor author ID, so thecreateDiffHTMLoutput carried script into any integration that renders it. Export now only emits#rgb/#rrggbbcolors and limits author class names to[A-Za-z0-9_-]. As an extra safeguard,.etherpadimport replaces a malformedcolorIdwith a palette color, matching the live socket validation. Adds backend regression tests. Reported by zx (@manus-pi).
- API —
movePadnow carries the pad's deletion token to the new id (#7995).movePadis implemented ascopy()+remove(), butPad.copy()only copies thepad:<id>,:revs:Nand:chat:Nrecords — neverpad:<id>:deletionToken— andremove()then deleted the source pad's token. The renamed pad therefore had no token at all: the token the creator had been told to save no longer deleted anything, and because the copy keeps the same revision-0 author, their next visit trippedcreateDeletionTokenIfAbsent()and popped a second "save your pad deletion token" modal. The token record is now handed over to the destination as part of the move, so the saved token keeps working and the modal does not reappear.force-overwriting an existing destination discards that pad's own token along with its content.copyPadis deliberately unchanged — two pads sharing one secret would let a token saved for one delete the other.
v3.3.3
3.3.3 is a security release. It closes a critical unauthenticated arbitrary-file-read in the /static/* handler (GHSA-mc8w-wjhw-45x5) and bundles the fixes for a batch of privately reported issues that had already landed on develop: an OpenID Connect provider hardcoded cookie key and permissive CORS reflection (GHSA-pp5v-mvwg-76mp), session-fixation on authentication (GHSA-73h9-c5xp-gfg4), a same-socket cross-pad write TOCTOU (GHSA-6mcx-x5h6-rpw2), and a pad-id delimiter injection in copyPad/movePad (GHSA-wg58-mhwv-35pq). Alongside the security work it migrates the server build to TypeScript 7 (tsgo), fixes PageDown/PageUp navigation across consecutive long wrapped lines, and makes the docker plugin_packages volume mountpoint writable.
- Prevent pre-auth path traversal / arbitrary file read in
/static/*(GHSA-mc8w-wjhw-45x5, #8081). On POSIX a backslash is an ordinary filename byte, sosanitizePathname()deliberately leaves an..\..\..segment untouched — butMinify.tsthen converted backslashes to forward slashes unconditionally, after the sanitiser, turning those bytes back into../traversal components with no re-check. Because the route is mounted onexpressPreSession(before the auth middleware), any unauthenticated client could read any file readable by the Etherpad process — e.g.GET /static/plugins/ep_etherpad-lite/static/..%5C..%5C..%5Cetc/passwd— escalating via disclosedsettings.json/credentials.json//proc/self/environto an admin session and, through the plugin installer, RCE. The backslash conversion is now guarded to Windows only (path.sep === '\\'), matching the invariant already enforced insanitizePathname.ts. Adds a backend regression test that fails on the pre-fix code. Reported by @gcm-explo1t. - Stop shipping a hardcoded OIDC cookie key and reflecting arbitrary CORS origins (GHSA-pp5v-mvwg-76mp, #8070, #8071, #8072). The embedded OpenID Connect provider shipped a hardcoded cookie-signing key (allowing forged provider cookies) and
clientBasedCORSreflected any requestOrigin. The provider now derives its cookie keys from the instance secret, CORS reflection is constrained, the soffice export path strips remote images to match the native path, and public routes that echox-proxy-pathsetVaryto prevent cache poisoning. Reported by meifukun. - Regenerate the session id on authentication (GHSA-73h9-c5xp-gfg4, #8074). Etherpad did not rotate the session identifier when a user authenticated, so a pre-auth session id fixed by an attacker (most impactfully via
ep_openid_connectSSO) survived login, enabling session-fixation account/admin takeover. The session id is now regenerated on the authentication boundary. - Apply queued
USER_CHANGESto the enqueue-time pad (GHSA-6mcx-x5h6-rpw2, #8075). A same-socketCLIENT_READYpad-swap could redirect an already-queuedUSER_CHANGESonto a different (read-only or unauthorized) pad, a cross-pad write. Queued changes are now bound to the pad they were enqueued against. - Reject the ueberdb key delimiter
:incopyPad/movePaddestination ids (GHSA-wg58-mhwv-35pq, #8073). A destination id containing:could bypass theforce=falseoverwrite guard and corrupt another pad's revision records. Such ids are now rejected.
- Migrate the server build to TypeScript 7 /
tsgo(#8039). The server now type-checks and builds under the native-Go TypeScript compiler.
- Editor — PageDown/PageUp now advance across consecutive long wrapped lines (#7555). Paging no longer stalls when several long soft-wrapped lines follow one another.
- Docker — make the
plugin_packagesvolume mountpoint writable (#8042). Mounting a plugin-packages volume no longer fails on a read-only mountpoint.
v3.3.2
3.3.2 is a bug-fix and dependency-hardening follow-up to 3.3.1. It rounds out the pad-deletion UX rework (suppressing the recovery token for durable identities, keeping the token-less Delete button reachable, and closing a read-only deletion hole), restores the saved-revision markers that went missing from in-pad history mode in 3.3.x, and adds env-var overrides so air-gapped installs can switch off Etherpad's outbound calls without editing the image. It also fixes the migrateDB / importSqlFile / migrateDirtyDBtoRealDB CLI scripts against the promise-based ueberdb2 API, rejects unreachable ./.. pad ids, and clears a batch of dependency security advisories (including CVE-2026-54285). On the CI side it unblocks the installer smoke test (which had been hanging the full 6-hour job ceiling since 3.2.0) and pins ueberdb2 past a startup-exit regression in the packaged boot.
- Force
@opentelemetry/core≥ 2.8.0 (GHSA-8988-4f7v-96qf / CVE-2026-54285, #7975). The transitive dep (pulled in via@elastic/elasticsearch→@elastic/transport) had aW3CBaggagePropagator.extract()that did not enforce W3C size limits on inbound baggage headers, allowing unbounded memory allocation. Pinned via apnpm-workspace.yamloverride; satisfies the existing2.xrange with no parent bump. - Resolve open Dependabot security alerts (#7967). Refreshes stale override floors and adds new ones via
pnpm-workspaceoverrides:form-data≥ 4.0.6,ws≥ 8.21.0,esbuild≥ 0.28.1,basic-ftp≥ 5.3.1 (capped<6.0.0to avoid a surprise major on the plugin-install path),tar≥ 7.5.16,js-yaml≥ 4.2.0,qs≥ 6.15.2,ip-address≥ 10.1.1, and@babel/core≥ 7.29.6. - Reject read-only deletion via token-less paths (part of #7959 / #7960). Under
allowPadDeletionByAllUsersa read-only viewer was grantedcanDeletePad=true, and the server'sflagOk/creatorOkbranches never checkedsession.readonly— so a read-only link holder could delete a pad without a token. Read-only sessions are now excluded from both the client var and the server's token-less authorization paths; a valid recovery token stays sufficient regardless of session mode.
- Pad deletion — suppress the recovery token for durable identities and relabel the action (#7926 / #7930). Building on the
allowPadDeletionByAllUserssuppression, a creator's deletion token is now also withheld when they have a durable identity — authenticated (req.session.userwith a username) and the deployment pins that identity to a stableauthorIDvia agetAuthorIdhook — since only then does the creator survive a cookie clear or a different device, making the token redundant. This tightens the previous "require authentication ⇒ always suppress" rule: withoutgetAuthorIdthe authorID still comes from the per-browser cookie, so an authenticated user on a second device is not the creator and keeps getting a token. A newcanDeleteWithoutTokenclient var hides the whole recovery-token disclosure (label, field, submit) when no token is needed, and the recovery form now renders for all sessions (hidden by default) so an authenticated creator without a durable mapping still has UI to enter their token.API.createPadreturns anulldeletionTokenunderallowPadDeletionByAllUsers, matching the socket/UI path. - Offline/air-gapped installs — env-var overrides for the update check, plugin catalog, and updater (#7917, addresses #7911). Firewalled deployments could not disable Etherpad's outbound calls without editing
settings.jsoninside the image. The relevant keys are now wired through the${ENV:default}substitution insettings.json.dockerandsettings.json.template:PRIVACY_UPDATE_CHECK,PRIVACY_PLUGIN_CATALOG,UPDATES_TIER(off= no calls),UPDATE_SERVER, plus the docker-onlyUPDATES_SOURCE/UPDATES_CHANNEL/UPDATES_CHECK_INTERVAL_HOURS/UPDATES_GITHUB_REPO/UPDATES_REQUIRE_ADMIN_FOR_STATUS. A new "Updates & privacy" section indoc/docker.mddocuments the set; backend tests parse the shipped configs and fail if the${ENV}placeholders are dropped. Config, docs, and tests only — no runtime code change.
- Pad — keep the token-less Delete button reachable without pad-wide settings (#7959 / #7960). The token-less
#delete-padbutton was nested inside theenablePadWideSettings-gated section, so disabling pad-wide settings removed the only no-token deletion path — and combined with #7926 hiding the token disclosure when no token is needed, a user allowed to delete could be left with no deletion UI at all. The button is now always rendered (hidden by default) and driven by acanDeletePadclient var (creator orallowPadDeletionByAllUsers, excluding read-only sessions), so the plain button and the recovery-token disclosure are mutually coherent and neither depends on pad-wide settings. - History mode — restore the saved-revision markers (#7946 / #7948). When #7659 moved the timeslider into the pad as an embedded iframe, the user-facing control became the outer
#history-slider-input, but the saved-revision stars were still drawn into the now-hidden iframe#ui-slider-bar, so "Save Revision" appeared to do nothing in in-pad history mode (a 3.3.x regression).pad_mode.tsnow bridges the embedded slider's saved revisions onto the outer slider as percentage-positioned, aria-hidden star markers (with click-to-seek for mouse users), and the server'sSAVE_REVISIONhandler broadcastsNEW_SAVEDREVto the pad room so a revision saved by a collaborator appears live on an already-open history slider. A single revision saved at rev 0 now renders too. Adds Playwright coverage for both the single-client and two-client live paths. - Import dialog — correct the outdated "no converter" help message (#7988 / #7989). The notice claimed only plain text and HTML could be imported and linked to the legacy AbiWord wiki, prompting LibreOffice installs for formats that already work natively. Etherpad imports
.txt,.html,.docx(via mammoth) and.etherpadwithout LibreOffice; only.pdf/.odt/.doc/.rtfstill need it. The message now says so and points at the documentation site. - PadManager — reject unreachable
.and..pad ids (#7962).isValidPadIdaccepted ids consisting only of URL dot-segments, but per the WHATWG URL standard a browser normalises/p/.to/p/and/p/..to/, so such a pad could be created in the database yet never opened or exported. These ids are now rejected, and the admindeletePadhandler falls back to a raw key purge whengetPad()throws so any legacy./..pad can still be removed.
- CLI — fix the database migration/import scripts against the ueberdb2 promise API (#7982 / #7983).
migrateDB.tsopened source and target databases, copied all keys, then resolved without closing either — so under ueberdb2 6.1.x the keep-alive timer kept the process hanging after "Done syncing dbs", and buffered target writes were only guaranteed flushed onclose(). It now closes both databases (flushing writes, clearing the timer) on success and error paths and exits with an explicit status.importSqlFile.tsandmigrateDirtyDBtoRealDB.tswere ported off the pre-v6 callback API toawait db.init()/db.set(k, v)/db.close(), removing two@ts-ignores that hid broken calls and fixing an undefinedlengthin a progress log;tsc --noEmiton the bin package is now clean. - CI — stop the installer smoke test hanging the 6-hour job ceiling (#7981). The "Installer test" had hung on every ubuntu/macOS run since 3.2.0:
pnpm run prodis a nested launcher, sokill "$PID"; wait "$PID"only signalled the outer pnpm and blocked forever if the node server didn't exit on SIGTERM. Teardown now runs the launcher in its own process group, kills the whole group (SIGTERM then SIGKILL), drops the blockingwait, and adds an 8-minutetimeout-minutesbackstop to both smoke steps. - CI — run the Debian-package smoke test on PRs (#7969). The packaged-boot smoke test previously ran only on push to
develop— i.e. after merge — which is why the ueberdb2 startup-exit regression turneddevelopred instead of being blocked at PR time. Apull_requesttrigger (scoped to production-footprint paths) now runs the build+smoke job on PRs; the release/apt-publish jobs stay tag-guarded. - Release — park the non-functional
ep_etherpadnpm publish (#7922). ThereleaseEtherpadworkflow republished./srcasep_etherpad, a package with zero dependents that nothing in the repo or any deployment path consumes, and it had been failing with E404 (no OIDC trusted publisher configured). The job is now gated behind an explicitconfirm: truedispatch input so a stray run fails fast with a clear message, with the status documented in the workflow header andAGENTS.MD. - Tests — port the orphaned legacy timeslider specs to Playwright (#7949). The
src/tests/frontend/specs/mocha suite is run by no CI workflow, so its timeslider coverage was dead — which is how the #7946 history-mode regression reached a release. The still-meaningful cases (revision labels, export links, deep-link entry) were ported tofrontend-newPlaywright specs re-targeted at the real in-pad UI, and the three now-ported legacy specs were deleted.
ueberdb2pinned to6.1.13. 6.1.10 rewrote the cache/buffer layer to lazily arm an.unref()'d flush timer only when there are dirty keys, so on a fresh empty dirty DB nothing anchored Node's event loop and the packaged (.deb/systemd) boot could exit cleanly (code 0) beforeserver.listen()bound the port — failing the Debian-package health check. The dep was pinned back to the last green release (6.1.9, #7969) and then rolled forward to the now-fixed6.1.13(#7979), pinned exactly rather than with a caret.nodemailer8.x → 9.0.1 (#7965 / #7950 / #7976),mongodb7.1.1 → 7.3.0 (#7941),pg8.21.0 → 8.22.0 (#7985),undici→ 8.5.0 (#7980 etc.),oidc-provider9.8.4 → 9.8.5 (#7973),pdfkit0.19.0 → 0.19.1 (#7945),semver7.8.3 → 7.8.4 (#7943), and@radix-ui/react-switch1.3.0 → 1.3.1 (#7974).- Dev/build dependency group updates (#7964, #7970, #7978, #7987, #7944, #7951, #7952, and others), including
@types/node25 → 26,esbuild0.28.0 → 0.28.1,eslint10.4.1 → 10.5.0,@playwright/test1.60 → 1.61,vitest4.1.8 → 4.1.9, andactions/checkout6 → 7 (#7977).
v3.3.1
3.3.1 is a small bug-fix and hardening follow-up to 3.3.0. It closes a stored-XSS vector in the numbered-list start attribute, hardens the database layer so a dropped connection to PostgreSQL / Redis / RethinkDB no longer crashes the process (via ueberdb2 6.1.9), and fixes a handful of pad and admin regressions — the iOS dark-mode status bar, the settings language dropdown, the pad-deletion modal under allowPadDeletionByAllUsers, and a single unreadable pad blanking the admin Manage-pads list.
- Pad editor — escape and integer-coerce the numbered-list
startattribute (GHSA-f7h5-v9hm-548j, #7937). A crafted<ol start>value flowed unescaped intodomline.ts, a distinct client-side sink from the export-path fix in 3.3.0's #7905. The value is now integer-coerced and HTML-escaped before it reaches the DOM. A jsdom regression test covers the sink.
- Skin — paint the root canvas so iOS dark mode has no white status bar (#7606 / #7931). iOS Safari paints the top safe area from the
htmlroot background, whichtheme-color(an Android address-bar hint) does not affect, so dark-mode pads showed a white status-bar strip on iOS. Colibris now sets the root background andcolor-schemeso the safe area matches the editor. - Settings — show the detected language in the dropdown (#7925 / #7928). The settings language
<select>did not reflect the language Etherpad had actually auto-detected; it now shows the active selection. - Pad — don't issue a deletion token (or show its modal) when
allowPadDeletionByAllUsersis on (#7929). With pad deletion open to all users the client still minted a deletion token and surfaced the confirm modal; both are now suppressed in that configuration. - Admin — one unreadable pad no longer empties the Manage-pads list (#7935 / #7938). A single pad that failed to read could throw out of the list-hydration path and blank the entire admin Manage-pads view; the read is now guarded per-pad so the rest of the list still renders.
- CI — downstream client compatibility gate (#7923 / #7924 / #7927). A new gate smoke-tests the published
etherpad-pad,etherpad-cli, andetherpad-desktopclients against the server build (Phase 1 + Phase 2), with robust per-client error handling inrun-clients.shso one client's failure is reported rather than masking the others. - CI — verify Etherpad boots offline (#7936). Adds a test step that confirms a built Etherpad starts with no network access.
ueberdb26.1.8 → 6.1.9 — PostgreSQL pool errors are now handled and TCP keep-alive is enabled (fixes #7878), and the Redis and RethinkDB drivers attach connection-error handlers so a dropped database connection no longer crashes the Etherpad process.semver7.8.2 → 7.8.3 (#7933),rate-limiter-flexible11.1.1 → 11.2.0 (#7934), plus a dev-dependencies group update (#7932).
v3.3.0
3.3 is primarily a security-hardening release. A defence-in-depth pass tightens the HTTP API entry points, switches random-id generation to a CSPRNG, escapes exported data-* attributes, and flips the shipped Docker deployment defaults so a fresh install no longer boots with implicit credentials or a trusting proxy. Alongside that, the ep_* pad-options passthrough that shipped opt-in in 3.0.0 is now on by default, the in-pad timeslider learns to honour the editor's view settings (authorship colours, font family, line numbers), and a long tail of pad-editor layout, RTL, and URL-encoding fixes lands. The release also carries the root-cause fix for the long-standing Windows backend-test "silent ELIFECYCLE" flake.
- Plugin pad options on by default —
settings.enablePluginPadOptionsnow defaults totrue(#7841). The flag that gates theep_*passthrough on pad options (shipped opt-in in 3.0.0, #7698) is flipped to default-on, so plugins such asep_plugin_helpers'padToggle/padSelectride the existing broadcast/persist rail out of the box. This closesep_comments_page#422— stock 3.x deploymentsconsole.warned on every pad load because the helper detectedenablePluginPadOptions === false. Thesettings.json.templateenv-var default is flipped to match, so Docker/supervisor configs without an explicit value get the new behaviour. Existing deployments with an explicit"enablePluginPadOptions": falsekeep that value — no migration needed — and the protocol shape is unchanged for older clients. - Timeslider — honour the editor's view settings (#7899). The in-pad timeslider now respects
showAuthorshipColors,padFontFamily, and line-numbers, bridged from the pad-settings checkboxes into the embedded timeslider iframe so the two views agree.nice-select.tsdispatches a nativechangeevent after the jQuery trigger so theaddEventListener-based bridge inpad_mode.tsfires (jQuery 3.7.1'strigger()does not dispatch native DOM events), and the font-family reset is fixed for jQuery 3 (which ignores anullcss value). The five ad-hoc listener stores inpad_mode.tsare consolidated into onebindOuter()path and the three view-setting bridges into a single data-drivenbridgeView()(refactor only). - Admin settings — explain env-var substitution and surface auth errors (#7819 / #7826). Three env-var-only UX improvements driven by #7819 (a Docker operator saved an
ep_oauthblock in the Raw view and reported it "disappeared", not realisingsettings.jsonon disk is a template, not the effective config): a banner above the editor explaining the template/substitution model (rendered only when the loaded file contains a${VAR}placeholder); a read-only Effective tab exposing the redacted runtime settings the backend already emitted asresolved(also gated on${VAR}); and anadmin_auth_errorevent so a misrouted Traefik+SSO session that isn't admin gets a clear toast instead of a silent "save did nothing". A reconnect-loop guard suppresses the SPA's auto-reconnect once an auth error has been received. No behaviour change for installs without${VAR}placeholders.
A defence-in-depth pass across the API, token, export, and deployment surfaces:
- HTTP API request handling, random IDs, and plugin loading (#7906).
pad_utils.randomStringnow generates random IDs viacrypto.getRandomValues(CSPRNG) instead ofMath.random.OAuth2Providercompares passwords withcrypto.timingSafeEqualon the raw UTF-8 bytes (resolving the CodeQL "insufficient computational effort" alert) behind a uniform failure delay, and looks users up via own-property access only.API.appendChatMessagethrowspadID does not existrather than creating the pad, consistent with the other content API methods. The/api/2REST router forwards only theauthorizationheader (not the full request header set) and falls back to it whenever the field is falsy, matching theopenapi.tshandler so both routers authenticate identically.LinkInstallervalidates plugin dependency names before building filesystem paths from them, and the admin file server returns a generic error while logging details server-side. - Escape exported
data-*attributes; warn on default/placeholder credentials (#7905).ExportHtmlnow escapes the name and value of attributes emitted by theexportHtmlAdditionalTagsWithDatahook, consistent with the URL/text escaping already applied to exported HTML.Settingslogs a warning (error level underNODE_ENV=production) when an account uses a default/placeholder password from the shipped config, and the check is extended to coversso.clients[].client_secretso enabling SSO without settingADMIN_SECRET/USER_SECRETis flagged the same way. - Docker deployment defaults — require explicit credentials, default
TRUST_PROXYoff (#7907). The shippeddocker-composenow requiresADMIN_PASSWORDand the database password to be provided explicitly (no implicit fallback) and defaultsTRUST_PROXYtofalse. Operators relying on the previous implicit defaults must now set these values explicitly.
- History mode — lay the timeslider iframe in the editor's flex slot (#7903). In-pad history mode positioned
#history-frame-mountas aninset:0absolute overlay over#editorcontainerbox, which took the iframe out of flow and hid any in-flow side panel (e.g.ep_webrtc's#rtcboxvideo column) beneath it — so history mode and live mode disagreed. The iframe now occupies the same in-flow flex slot the live editor uses, and a latent specificity bug (thebody.history-mode #editorcontainer { display: none }hide rule was outranked by the two-id layout rule, so the live editor was only ever painted over) is fixed by giving the hide rule matching specificity. Adds apadmode.spec.tsregression test. - Pad editor — restore URL wrapping (#7894 / #7896). Long URLs in the pad editor overflowed instead of wrapping because the global
a { white-space: nowrap }rule overrode the wrapping properties on#innerdocbody. Explicitwhite-space/word-wrap/overflow-wrapon#innerdocbody arestores wrapping inside the editor while preserving no-wrap for links elsewhere in the UI. - RTL content option no longer flips the whole page (#7900 / #7901). The per-pad RTL content option (
rtlIsTrue) wrote the direction to the top-leveldocument.documentElement, flipping the entire page — toolbar and chrome included. The content direction is now applied to the inner editor document (targetDoc.documentElement); page direction stays owned by the UI language (l10n.ts). Adds a frontend test asserting the inner editor flips while the top-level<html>dir is unchanged. - Pad-wide view settings apply to the creator's own view (#7900 / #7902). Because a creator is never "enforced upon themselves", a stale personal view-override cookie (e.g.
rtlIsTrue=falsefrom an earlier toggle) silently masked the pad-wide value they later set, so the control appeared to do nothing on their own screen. Changing a pad-wide view option now syncs the creator's personal pref to the chosen value; the precedence model is unchanged (the creator can still override afterwards via "My view"). - URL view-option params lost to a
padeditor.initrace (#7840 / #7843).?showLineNumbers=falseand?useMonospaceFont=truewere silently clobbered shortly after load — the same race #7464 fixed for?rtl=false, but the neighbouringshowLineNumbers/noColors/useMonospaceFontGlobalblocks were left at the synchronous-tail site. The fix is generalised to all three (moved intopostAceInit). Mostly observable in cross-context iframe embeds that start with noprefscookie. Addsurl_view_options.spec.ts. - Default welcome text attributed to the system author (#7885 / #7887). Auto-generated default pad content (
settings.defaultPadText/padDefaultContenthook) carried the creating user'sauthorattribute and rendered in their authorship colour, even though they never wrote it. The welcome text'sauthorattribute is nowPad.SYSTEM_AUTHOR_ID, while revision 0'smeta.authorstays the real creator so ownership (pad-wide settings gate, deletion token) is preserved. Explicitly provided text (e.g. HTTP APIcreatePadwith text + author) keeps the real author. - URL-encode pad names in the admin 'Open' button and recent pads (#7865 / #7895). Pad names are
encodeURIComponent-d in the adminPadPageOpen href and the colibris recent-pads href, anddecodeURIComponent-d when read back from the URL pathname; legacy URL-encoded recent-pads names are normalised before re-encoding to prevent double-encoding (%2F→%252F). The admin Openwindow.opengainsnoopener,noreferrer. - OIDC — fix broken
OIDCAdapterflows (#7837). Repairs the adapter flows and widens the storage type to includestringfor theuserCodeindex; adds regression tests. - Accessibility — dialog titles/descriptions and a missing l10n key (#7835 / #7836). Adds the
index.codekey referenced byindex.htmlbut never defined (which produced a "Couldn't find translation key" console error on the landing page), and gives every admin@radix-ui/react-dialogDialog.ContentaDialog.TitleandDialog.Description(visually hidden where there's no visible heading), silencing Radix's a11y warnings. A new backend spec fails CI if anydata-l10n-idinsrc/templates/*.htmlis missing fromen.json. - Offline/air-gapped Docker boot — stop pnpm self-provisioning a pinned version (issue #7911). The official image installs pnpm directly (corepack was dropped for Node 25+). Because the image's pnpm intentionally lags the
packageManagerpin inpackage.json(pnpm 11.1.x enforces a minimum-release-age policy the frozen-lockfile build can't satisfy), pnpm treated every call — including the informationalpnpm --versionprobe Etherpad runs at startup — as a request to download the pinned build. Behind a firewall that download failed (Failed to get pnpm version: … Command exited with code 1), breaking startup. The Dockerfile now setspnpm_config_pm_on_fail=ignore, and the startup probe plus the updater's pnpm-on-PATH checks run with the same flag, so pnpm uses the installed version instead of reaching for the network (without changing which pnpm runs the build-time install). A backend spec fails CI if that guard is dropped while a version gap exists. - Firefox authorship colours — tag early keystrokes with the right author (#7910). The inner editor's
thisAuthorstarts empty and is only populated when collab_client's queuedsetProperty('userAuthor', userId)reaches the iframe (applied asynchronously viapendingInit). Under Firefox timing the first keystrokes could beat it, so freshly typed text — and early line-attribute changes (lists, headings, alignment) — were taggedauthor='', which canonicalises to an unattributed insert that the server's pad-corruption guard rejects, dropping the whole change and losing authorship (the intermittentclear_authorship_colorflake, where undo couldn't restore the author colour). AgetLocalAuthor()helper now falls back toclientVars.userId(the same id, available synchronously) wheneverthisAuthoris still empty, applied at the text-insert sites and to seeddocumentAttributeManager.author; the intentional clear-authorship path and the server-side guard are unchanged. - Dark mode — fix the white address bar and the light-flash on load (#7909, issue #7606). Dark-mode users still saw a white mobile address bar above the dark toolbar, and the whole page flashed light before going dark. Both came from rendering the light state server-side and switching to dark only after the JS bundle ran: iOS Safari reads
theme-colorat parse time and doesn't reliably repaint on a later JS mutation, and the page painted light before the bundle applied the dark skin classes. The server now emits aprefers-color-scheme-scopedtheme-colorpair so the address bar is correct at first paint, plus a small blocking<head>script that applies the dark skin classes before the stylesheet paints. Both are gated onenableDarkMode(default on) and the colibris skin;pad.tsstill runs on init to wire up the#options-darkmodetoggle (which now updates everytheme-colormeta) and theme the editor iframes. Applies to the pad and timeslider views.
- Root-caused and fixed the Windows backend-test "silent ELIFECYCLE" flake (#7866). The ~22% Windows flake — rotating across random spec files, no mocha summary, no JS trace — was diagnosed from a full-memory dump as two distinct causes. (1) A timing-fragile test abandoned by mocha keeps running and later throws an orphan unhandled rejection;
server.ts's process-globaluncaughtException/unhandledRejectionhandlers (correct for a real Etherpad process) escalated that into a cleanprocess.exit. They are now gated behindrequire.main === module, and the backend-test bootstraps (common.ts,diagnostics.ts) log orphan rejections instead of rethrowing. (2) A stack-buffer overrun in Node 24.x's bundled libuv Windows TCP-connect path (uv__tcp_connect) corrupts memory under the suite's localhost-connection churn; CI pins the Windows backend job to Node 24.16.0 (libuv 1.52.1, the bisected fix), referencing upstreamnodejs/node#63620. Linux stays on Node 24 LTS. - Removed the now-unneeded ELIFECYCLE diagnostic scaffolding (#7846 / #7838 / #7842 / #7868). The OS-level sidecar watcher, the diagnostics heartbeat/running-test pointer, and the mid-test snapshot — added to chase the flake above — are removed now that the cause is known.
- Docs — document the Docker
settings.jsonwritable-layer and env-var-vs-file semantics (#7819 / #7827). Two operator-facing gaps surfaced by #7819: that the on-disksettings.jsonis a template (env substitution happens in memory at load time), and that the default compose putssettings.jsonin the container's writable layer with no host mount, so admin edits are lost ondown/pull/watchtower but survive a plainrestart. Adds prose + a recreate-vs-restart table todoc/docker.mdand a commented-out opt-in bind mount to the compose files. - Docs refresh for 3.2.0 (#7888), dropped three redundant top-level files (#7839), dropped a fragile viewport assertion in the enter test (#7845), and a backend-test fix-up.
- Two major bumps:
redis5.12.1 → 6.0.0 (#7869) andejs5.0.2 → 6.0.1 (#7860). ueberdb26.1.2 → 6.1.8,mssql12.5.3 → 12.5.5,nodemailer8.0.7 → 8.0.10,mysql23.22.3 → 3.22.5 (#7915),undici8.3.0 → 8.4.1 (#7914),pdfkit0.18.0 → 0.19.0 (#7916),oidc-provider9.8.3 → 9.8.4,@elastic/elasticsearch9.4.1 → 9.4.2,lru-cache11.5.0 → 11.5.1,rate-limiter-flexible11.1.0 → 11.1.1,semver7.8.1 → 7.8.2,js-cookie3.0.7 → 3.0.8,tsx4.22.3 → 4.22.4,@radix-ui/react-switch1.2.6 → 1.3.0 (#7913),@tanstack/react-query5.100.11 → 5.101.0 (+ devtools), plusi18next,react-router-dom, and several dev-dependency group bumps (#7912).
- Multiple updates from translatewiki.net.
v3.2.0
3.2 adds first-class reverse-proxy / ingress support — X-Forwarded-Prefix and X-Ingress-Path are now honoured under trustProxy, so Etherpad can live under a subpath (Traefik, Nginx, Kubernetes Ingress) without breaking the PWA manifest, social-meta URLs, or any of the bootstrap asset links. The admin settings page learns to show resolved runtime values next to ${VAR:default} placeholders, the v3.1.0 admin pad-list filter chips now apply server-side (so "show empty pads" no longer returns 0–12 of hundreds), and the v3.1.0 redesigned outdated-version gritter actually fires in production now (the session-based author lookup it shipped with always returned null for pad visitors).
- HTTP — accept
X-Forwarded-PrefixandX-Ingress-PathundertrustProxy(#7802 / #7806). WithtrustProxy: true, Etherpad now honoursX-Forwarded-Prefix(de-facto Traefik / Spring) andX-Ingress-Path(Kubernetes Ingress) in addition to the prefix it already inferred from the request path. The sharedsanitizeProxyPathhelper added in 3.1.0 (defence-in-depth:[A-Za-z0-9_./-]only,//+collapsed,..traversal rejected) is extended to the new headers and applied consistently across/manifest.json,socialMetaog:url/og:image, and theindex.html/pad.html/timeslider.html/export_html.htmltemplates (manifest links, jslicense links, reconnect URLs). A pre-existing..segment-count miscalculation inpad.html/timeslider.htmlthat broke the manifest link when served from a deep subpath is also fixed in passing. New end-to-end suite covers the prefix-applied / prefix-ignored matrix undertrustProxy=true|falsefor both header names.settings.json.templatedocuments the new headers alongside the existingtrustProxynotes. - Admin settings — resolved runtime values surface on env-pill chips (#7803 / #7807). The
/admin/settingssocket payload now carries a newresolvedfield alongside the existing raw-fileresultsblob, carrying the actual in-memory settings module run through a new redactor (AdminSettingsRedact) that replaces known-sensitive paths (users.*.password,dbSettings.password,sso.clients[*].client_secret,sessionKey, …) with[REDACTED]. The admin SPA'sEnvPillrenders a→ active valuechip when the path is resolved, or→ ••••••with a redacted tooltip when the server returned the sentinel — soport: ${PORT:9001}now shows→ 9001(or whatever the live value is) instead of silently falling back to the template default. Old admin SPAs that don't readresolvedcontinue to work; the save round-trip is unchanged so${VAR:default}literals are still preserved verbatim on disk. The admin test script glob picks up.test.tsxalongside.test.tsso the newEnvPillandresolveByPathtests run undertsx --test.
- Admin pads — filter chip now applies server-side, before pagination (#7798). The 3.1.0 admin pad-list filter chips (
active/recent/empty/stale) ran on the client after the 12-row page slice had already arrived. On a deployment with hundreds of pads, clicking "empty pads" on page 1 only matched the 0–12 empties that happened to land in the current page, with the pagination footer reporting nonsense totals (reported on a 3.1.0 deployment). The filter is now part of thepadLoadsocket query — pattern filter on names runs first (cheap), metadata hydration for the matching pad universe is gated on a non-allfilter or a non-padNamesort and runs under a 16-way concurrency cap (was unboundedPromise.all, which fanned out to thousands of in-flightpadManager.getPad()reads on busy deployments), then the filter chip, then sort + slice.totalreflects the filtered universe so the footer makes sense. Older admin clients that don't sendfilterkeep working — the server defaults toall. Theif/else ifladder that duplicated the hydrate-and-sort loop persortByis folded into one pipeline with a single comparator switch. - Pad outdated notice — author now resolved from token cookie, not session (Qodo #7804 / #7805). The 3.1.0 redesigned outdated-version gritter never fired in production.
resolveRequestAuthor()looked for anauthorIDonreq.session.user, which Etherpad does not populate for pad visitors (express-session only carries the admin-login user), socomputeOutdated()always returned EMPTY. The lookup now mirrors how the socket.io handshake resolves pad-visitor identity — read the HttpOnlytoken(or<prefix>token) cookie and callauthorManager.getAuthorId(token, user)via a dynamic import (same circular-init guard pattern the file already uses forPadManager). The admin OpenAPI document gains adescriptionnote clarifying that/api/version-statusis a public pad-side endpoint that lives in the admin doc only because it shares the same internal route registration. - Localisation — silence spurious "could not translate element content" warning (#7797).
<select data-l10n-id="…">with<option>element children — the pattern used byep_headings2,ep_align,ep_font_size,ep_font_family, … — used to drop into the textContent branch ofhtml10n.translateNode, hunt for a text-node child to overwrite, find none, and emitUnexpected error: could not translate element content for key …on every pad load. TheSELECT/INPUT/TEXTAREAaria-label fallback already lived inside the same else-branch after the warning, so the accessible name landed correctly but the noisy console line still fired. Form-control elements now short-circuit into the aria-label path before the text-node hunt — aria-label is the only sensible localization target for these elements (a<select>'s text is its<option>labels, not its own name). Closes the console warning reported on Etherpad 3.1.0.
- CI — swap archived
ep_readonly_guestforep_guestin the plugin matrix (#7795 / #7808).ep_readonly_guestis archived (read-only on GitHub) and itsauthenticatehook unconditionally swappedreq.session.userwith a read-only guest, even when the request carried an HTTP Authorization header. That silently demoted admin login attempts and stalled theanonymizeAuthorSockettests for 14 min/run on every with-plugins CI matrix. The pre-fix theory from 3.1.0 (#7796) blamedep_hash_auth.handleMessage; that was a red herring —handleMessageonly fires on the/padnamespace, never on/settings.ep_guestis the maintained successor (same authors, same purpose); 1.0.72 on npm already defers to basic auth / admin paths. Swapping the matrix unblocks theanonymizeAuthorSocketsuite on Linux, Windows, and the upgrade-from-latest-release workflow. The runtime probe added in #7796 stays — it still catches any other authenticate-hook plugin that rejects the test's plain-text credentials (e.g. a future hashed-only plugin). - Tests — admin
saveSettingsround-trip + cross-restart persistence (#7819 / #7820 / #7821). The adminsaveSettingssocket had zero direct backend coverage and the existing e2e "restart works" test only checked that the page renders after a restart, neither of which catches a deployment that resetssettings.jsonon restart, nor the user-visible workflow that triggered #7819 (add a top-level plugin block via Raw, save, watch it disappear). Three new backend specs (adminSettingsSave.ts) verify byte-for-byte write, top-level-block augmentation round-tripping through the nextload, and/* */comments surviving the write path. A new e2e spec mirrors the #7819 user workflow — open Raw, prepend anep_oauth-shaped top-level block, save,restartEtherpad(), re-login, confirm the block is still in Raw and surfaces as its own Form-view section (Ep oauthfromhumanize()). A separatedocker.ymljob (adminSettings_7819.ts) authenticates viaPOST /admin-auth/(always-requireAdmin, regardless ofsettings.requireAuthentication), saves a hand-built minimal-but-viable settings document containing a marker block,docker exec test greps for it,docker restarts the container, waits for the health probe, and re-greps. Both checks must pass. - Bug report template now asks contributors whether the abstraction in their proposed fix matches the rest of the codebase, to head off premature-generalisation fixes earlier in review.
ueberdb26.0.3 → 6.1.2 (two patch releases of cleanup on top of the 6.1.0findKeysPagedAPI that the 3.1.0 sessionstorage OOM fix relies on).semver7.8.0 → 7.8.1,lru-cache11.3.6 → 11.5.0,@elastic/elasticsearch9.4.0 → 9.4.1,pg8.20.0 → 8.21.0,openapi-backend5.16.1 → 5.17.0,tsx4.22.0 → 4.22.3,@tanstack/react-query5.100.10 → 5.100.11 +@tanstack/react-query-devtools,js-cookie3.0.6 → 3.0.7, plus two dev-dependency group bumps.
- Multiple updates from translatewiki.net.
v3.1.0
3.1 ships the self-update programme's Tier 4 — autonomous in a maintenance window for real (the v3.0.0 notes documented the design; this is the release the code actually lands in), adds first-class SMTP delivery so update failures email the admin, and bundles a defence-in-depth pass across the HTTP/API entry points. Two new admin-facing escape hatches arrive: a preflight check that aborts an update before it mutates the working tree when the target tag's engines.node doesn't match the running runtime, and email notifications for every auto-rollback / preflight outcome (not just the terminal rollback-failed state).
- Self-update — Tier 4 (autonomous in a maintenance window). Set
updates.tier: "autonomous"together withupdates.maintenanceWindow: {"start":"HH:MM","end":"HH:MM","tz":"local"|"utc"}to constrain autonomous updates to a nightly window. The scheduler snapsscheduledForforward to the next window opening when grace would otherwise land outside the window, and defers the fire when the window has closed by the timer callback. Cross-midnight windows (end < start) are supported; DST transitions are absorbed by host wall-clock arithmetic. A missing or malformed window degrades the policy to Tier 3 with an explicitpolicy.reasonofmaintenance-window-missing/maintenance-window-invalid; an admin banner surfaces the misconfiguration so autonomous behaviour is not silently disabled. The admin update page shows a "Maintenance window" section with the parsed window summary, the next opening, and a "deferred until " subtitle on the scheduled panel when the timer has been snapped forward. Closes #7607 (#7753). - Updater — real SMTP via nodemailer (new top-level
mail.*block). Replaces the "(would send email)" stub. New settings:mail.host,mail.port,mail.secure,mail.from,mail.auth.{user,pass}.mail.host=nullkeeps the legacy log-only behaviour. Thenodemailerdependency is lazy-imported on first send so installs that don't configure mail pay no runtime cost; the transport is cached on the full SMTP options tuple so areloadSettings()change to host/port/credentials invalidates the cache.settings.json.dockerreadsMAIL_HOST/MAIL_FROM/MAIL_PORT/MAIL_SECUREfrom env. Send errors are logged warn and swallowed so a transient SMTP failure can never poison the updater state machine. - Updater — preflight against the target tag's
engines.node. Before mutating the working tree,runPreflightnow runsgit show <tag>:package.jsonand verifiesprocess.versions.nodesatisfies the target'sengines.node. A mismatch fails cleanly atpreflight-failedwith the detailtarget requires Node >=X, running Y— no drain, no restart, no rollback. The check runs after signature verification so we only trust signedpackage.json. NewPreflightReason: 'node-engine-mismatch'. - Updater — email admin on rollback / preflight-failed (not just
rollback-failed). Before this release only the terminalrollback-failedstate emailed. Auto-recovered failures (rolled-back-install-failed,rolled-back-build-failed,rolled-back-health-check,rolled-back-crash-loop) andpreflight-failednow also fire one email per<outcome>:<targetTag>(dedupe key inEmailSendLog.lastFailureKey). A 3am autonomous update that rolls back because of, say, a Node engine bump now lands in the admin inbox at 3am instead of staying invisible until the next admin login. Boot-path catch-up covers cases where the failure preceded a clean process exit (timer-fired health-check rollback, crash-loop forced rollback, preflight-failed that didn't get to email before exit). - API —
listAuthorsOfPadfilters the synthetic system author.Pad.SYSTEM_AUTHOR_ID(a.etherpad-system) is the placeholder Etherpad attributes to when the HTTP API receives a call without anauthorId(setText, setHTML, appendText, server-side import). It was leaking throughlistAuthorsOfPad, making pads with only API-driven content appear to have one "real" author. The synthetic id is now filtered at that API surface only —getAllAuthors()and downstream callers (copy, anonymize, atext verification) still see it. Fixes #7785 / #7790 (#7793).
- Export HTML — ordered-list counter no longer poisoned by a sibling unordered list. When an ordered-list level was the only consumer of
olItemCounts, closing any list at that depth (including a<ul>that happened to share the level) reset the counter to 0. A subsequent unrelated<ol>at the same depth then took the "counter exists but is 0" branch and emitted<ol class="...">without thestart=attribute. The reset is now gated online.listTypeName === 'number'so closing an unordered list never touches the ol bookkeeping. Fixes #7786 / #7787 (#7791). - Export — bad
:revreturns a meaningful 500 body, not Express's HTML error page. A non-numeric:rev(e.g./p/foo/test1/export/txt) reachedcheckValidRevwhich throwsCustomError('rev is not a number', 'apierror'); the message fell through.catch(next)and Express's default renderer returned an HTML 500 page. The route handler now catches the apierror and emitserr.messageas a deterministictext/plain500. As a follow-up,checkValidRevruns beforeres.attachment()so an invalid rev no longer leaves aContent-Dispositionheader in place (browsers were offering to save the error message as a file), and unrelated export failures (conversion, fs, soffice) are surfaced as text/plain rather than the HTML stack page. Fixes #7788 (#7792).
A bundle of defence-in-depth tightening picked up during an internal audit pass (#7784):
- HTTP API — OAuth JWT path. Verify the signature before reading any claim off the payload; require
admin: truestrictly (presence is no longer sufficient). The apikey comparison switches tocrypto.timingSafeEqual. - Import/Export temp-file path tokens. Derived from
crypto.randomBytes(16)instead ofMath.random(). - Token transfer. Records now have a 5-minute TTL and are single-use (removed from the store before responding). The author token is no longer in the redemption response body — the
HttpOnlycookie is the only delivery channel. x-proxy-pathheader sanitiser (newsrc/node/utils/sanitizeProxyPath.ts). Shared byadmin.tsandspecialpages.ts. Strips characters outside[A-Za-z0-9_./-], collapses leading//+to a single/, rejects..traversal.admin.tsalso emitsVary: x-proxy-pathandCache-Control: private, no-storeso a poisoned response can never be reused for another origin.Pad.appendRevisioninsert-op author invariant. Centralises the "every insert op carries anauthorattribute" rule the socket handler already enforced, so non-wire callers (setText,setHTML,restoreRevision, plugin paths) get the same check.Pad.initandsetPadHTMLsubstituteSYSTEM_AUTHOR_IDwhen no author is supplied — same patternsetText/spliceTextalready used.setPadRawlegacy-import rewrite. Bulk-import bypassesappendRevision, so a hand-crafted.etherpadfile could persist non-conforming records that any subsequentsetText/setHTMLwould refuse to extend. A pre-pass now walks revs in order, sanitises each changeset's+ops against the cumulative pad pool (substitutingSYSTEM_AUTHOR_IDwhere needed), and re-applies each changeset to a running atext so the head atext and key-revmeta.atext/meta.poolsnapshots stay in lock-step. Conforming payloads round-trip unchanged.
- Backend tests —
tests/backend/specs/{api,admin}/*un-skipped. The pnpm test script's glob (tests/backend/specs/**.ts) only matched depth-1 files. Every spec underapi/(14 files) andadmin/(2 files) has been silently skipped by CI. Switched to--extension ts --recursiveso mocha walks the tree as documented. A new vitest regression check reads the pnpm script, hands mocha the same arguments under--dry-run --list-files, and asserts representative specs from both subdirectories appear in the discovered list (#7789). - CI — Windows
npx ENOENTin the glob-discovery regression check.execFileSync('npx', ...)doesn't pick upnpx.cmdon Windows runners. Resolved by runningmocha's JS entry directly viarequire.resolveunder the current node process. Path normalisation now goes throughpath.relative+replace([\\/])so mixed-separator / drive-letter casing on Windows mocha output still matches the POSIX-relative assertions (#7794). - CI —
anonymizeAuthorSocketsuite gated on admin-socket health whenep_hash_authis installed. Un-hiding the suite in #7789 surfaced a 14-minute stall on every with-plugins matrix run becauseep_hash_auth'shandleMessagehook fires for every socket message regardless of namespace and reads from the deprecatedclientcontext (undefined for non-pad namespaces). Until the root cause lands (tracked in #7795), the suite skips itself when an application-level probe shows the admin/settingsnamespace isn't responding — keeps the no-plugin matrix covered and stops burning ~14 minutes per with-plugins run (#7796).
- Multiple updates from translatewiki.net.