-
-
Notifications
You must be signed in to change notification settings - Fork 3k
.pr_agent_accepted_suggestions
| PR 7569 (2026-04-20) |
[security] Overprivileged PR token
Overprivileged PR token
The workflow runs on pull_request but now grants GITHUB_TOKEN `packages: write`, so untrusted PR-controlled code executed in the `Test` step can publish/overwrite GHCR packages despite the explicit GHCR login/push steps being gated to `push`. This increases CI blast radius and enables supply-chain compromise via CI token abuse.The workflow grants packages: write at the workflow level while still running on pull_request, which exposes package publish capability to PR-executed code.
Top-level permissions: apply to the entire workflow run (including pull_request runs), even when individual publish steps are gated by if: github.event_name == 'push'.
- .github/workflows/docker.yml[2-17]
- .github/workflows/docker.yml[64-122]
- Keep the PR test job with minimal permissions (for example only
contents: read). - Create a separate
publishjob that runs only onpush(useif: github.event_name == 'push') and setpermissions: packages: writeat the job level for that job only. - Optionally make
publishdepend on the test job (needs:) so pushes only publish after tests pass.
[maintainability] Docs miss GHCR option
Docs miss GHCR option
After adding `ghcr.io/ether/etherpad` as a publish target, Docker documentation still states the official image is only on Docker Hub and provides only Docker Hub pull commands. This makes the new GHCR mirror effectively undiscoverable for users relying on project docs.Documentation only references Docker Hub, but the workflow now publishes the same tags to GHCR.
Users following docs will not discover the GHCR mirror and may continue to hit Docker Hub rate limits.
- doc/docker.md[1-13]
- README.md[104-113]
Update Docker docs (and optionally README) to mention GHCR as an alternative mirror and add example pull commands such as:
docker pull ghcr.io/ether/etherpad:latest-
docker pull ghcr.io/ether/etherpad:<version>while keeping Docker Hub as canonical if desired.
| PR 7552 (2026-04-19) |
[correctness] Installer clones wrong repo
Installer clones wrong repo
README.md now instructs users to run installers from the `ether/etherpad` repo, but both installer scripts still default to cloning `https://github.com/ether/etherpad-lite.git`, so the one-liner can install a different repo than the documentation implies.README.md now points users to run installers from https://raw.githubusercontent.com/ether/etherpad/..., but the installer scripts still default to cloning https://github.com/ether/etherpad-lite.git. This can cause the one-liner install path to clone an unexpected repository.
- README is the primary entry point for users.
-
bin/installer.shandbin/installer.ps1embed their own default clone URL.
- bin/installer.sh[5-12]
- bin/installer.sh[33-38]
- bin/installer.ps1[1-11]
- bin/installer.ps1[37-41]
- README.md[71-88]
- Change the default repo URL in both installer scripts to
https://github.com/ether/etherpad.git. - Update the usage examples/comments in both scripts to match the new raw GitHub URLs.
- Ensure README and installer scripts agree on the repo being installed (directory name can remain
etherpad-liteif intentional).
[observability] Broken Docker badge link
Broken Docker badge link
README.md links the Docker workflow badge to `actions/workflows/dockerfile.yml`, but this repo’s Docker workflow file is `docker.yml`, so the badge/link will 404.The README Docker CI badge points to actions/workflows/dockerfile.yml, but the repository workflow file is docker.yml. This breaks the badge and link.
The repo contains .github/workflows/docker.yml.
- README.md[35-39]
- .github/workflows/docker.yml[1-5]
Update the Docker badge/link in README to reference actions/workflows/docker.yml (both the badge.svg URL and the clickable link).
[maintainability] Conflicting GitHub URLs
Conflicting GitHub URLs
Startup logging now directs users to `https://github.com/ether/etherpad/issues`, but the default pad text still advertises `https://github.com/ether/etherpad-lite`, giving users conflicting guidance on where the project lives.User-facing text references two different GitHub repositories (etherpad vs etherpad-lite). This is confusing for users looking for source/issues.
- Startup console message points to
https://github.com/ether/etherpad/issues. - Default pad contents still include
https://github.com/ether/etherpad-lite.
- src/node/hooks/express.ts[67-72]
- src/node/utils/Settings.ts[390-397]
Update defaultPadText to point at the same GitHub repo as the startup message (and consider doing the same for other prominent user-facing URLs if they exist).
| PR 7545 (2026-04-18) |
[reliability] Pad settings missing feature flag
Pad settings missing feature flag
The PR introduces creator-owned pad settings (including enforcement and chat suppression) and changes new-pad defaults, but the new behavior is enabled unconditionally with no feature flag or default-off gating. This violates the requirement to gate new features behind a disabled-by-default feature flag to preserve prior default behavior.Creator-owned pad settings and new-pad default seeding are enabled unconditionally. Compliance requires new features be behind a feature flag that is disabled by default, preserving the old behavior when the flag is off.
padSettingsDefaults is always sent in CLIENT_READY, and on first pad creation the server persists these defaults, changing behavior for all deployments.
- src/static/js/pad.ts[263-271]
- src/node/handler/PadMessageHandler.ts[901-907]
- src/templates/pad.html[170-220]
[maintainability] Cookie documentation not updated
Cookie documentation not updated
The PR changes how the `language` cookie is set (moved to new My View logic) and introduces new preference behavior, but repository documentation is not updated accordingly. This makes the documented cookie behavior inaccurate for users/admins.Documentation about cookies is now inaccurate due to the changes in where/how the language cookie is set and the new My View preference behavior.
doc/cookies.md currently points readers to the old implementation location for language. The PR changes the implementation but does not update docs.
- doc/cookies.md[5-10]
- src/static/js/pad.ts[546-550]
- src/static/js/pad_editor.ts[58-82]
[security] Pad creator can be lost
Pad creator can be lost
handlePadOptionsMessage() calls padManager.getPad(session.padId) without passing the session authorId, so if a padoptions message is processed before the normal pad creation path completes, the pad can be created with revision 0 author set to '' and isPadCreator() will fail for everyone (locking pad settings/delete).handlePadOptionsMessage() calls padManager.getPad(session.padId) without providing session.author. If the pad does not yet exist at that moment, PadManager.getPad() will create it with the default authorId = '', which makes revision 0’s author empty. Because creator checks rely on revision 0’s author, the pad can end up with no valid creator.
- Creator is determined via
pad.getRevisionAuthor(0). -
PadManager.getPad()usesauthorId = ''by default.
- src/node/handler/PadMessageHandler.ts[267-295]
- src/node/db/PadManager.ts[109-144]
- In
handlePadOptionsMessage(), avoid implicitly creating a pad with an empty author: - Option A (preferred): Check existence first (
doesPadExist). If it does not exist, reject the message (or defer until after CLIENT_READY completes). - Option B: Call
padManager.getPad(session.padId, null, session.author)so any accidental creation still assigns the correct creator. - Keep the creator authorization check, but ensure the pad is not created as a side effect of unauthorized/early messages.
[correctness] Defaults override server config
Defaults override server config
getMyViewDefaults() hard-codes fallback defaults (e.g., showChat/showLineNumbers true) when cookies are absent, and these values are sent as padSettingsDefaults for new pads, overriding server-side defaults such as settings.padOptions.showChat/showLineNumbers.New pads are seeded from padSettingsDefaults, but the client computes these via getMyViewDefaults() which uses hard-coded fallbacks like showChat: true and showLineNumbers: true when cookies are unset. This makes newly created pad defaults override server/admin defaults (e.g., settings.padOptions.showChat), because the server only applies its defaults when the incoming value is null/undefined.
- Server defaulting behavior is in
Pad.normalizePadSettings(). - Client sends
padSettingsDefaultsinCLIENT_READY.
- src/static/js/pad.ts[187-271]
- src/node/handler/PadMessageHandler.ts[898-907]
- src/node/db/Pad.ts[87-103]
- Replace
padSettingsDefaults: getMyViewDefaults()with a payload that only includes explicit user overrides (no hard-coded fallbacks). For example: - Send
getMyViewOverrides()instead ofgetMyViewDefaults(). - Ensure any
undefined/nullvalues are omitted so the server can applysettings.padOptions.*defaults. - Optionally, add a server-side guard when applying
padSettingsDefaultsto treat missing values as “unset” (so server defaults win) rather than trusting client fallbacks.
| PR 7542 (2026-04-18) |
[performance] Unthrottled line-number recalcs
Unthrottled line-number recalcs
broadcast.ts calls updateLineNumbers() synchronously on every applyChangeset(), forcing repeated full-document layout reads and DOM writes during timeslider scrubbing/playback and potentially making large pads very slow. scheduleLineNumberUpdate() also doesn’t coalesce repeated calls, so resize can queue many redundant recalculations.updateLineNumbers() is invoked in a hot path (per changeset) and resize scheduling is not coalesced. This causes excessive layout/reflow work, especially when scrubbing through many revisions or resizing the window.
The editor’s gutter update logic is run via an idle timer with a time budget; the timeslider should similarly throttle/coalesce updates.
- src/static/js/broadcast.ts[189-193]
- src/static/js/broadcast.ts[195-259]
- src/static/js/broadcast.ts[520-535]
- Add a
let lineNumberUpdatePending = falseflag. - Change
scheduleLineNumberUpdate()to no-op iflineNumberUpdatePendingis true; otherwise set it true and run a singlerequestAnimationFrame(() => { lineNumberUpdatePending = false; updateLineNumbers(); })(keep double-rAF only if you can justify it, but still coalesce). - Replace direct
updateLineNumbers()calls inapplyChangeset()withscheduleLineNumberUpdate()(or call direct only when needed and still coalesce). - Ensure initial render still triggers at least one update after DOM insertion.
| PR 7539 (2026-04-17) |
[reliability] `docx` export lacks flag
`docx` export lacks flag
DOCX export is newly enabled in both the server allowlist and the UI without any feature flag or disabled-by-default toggle. This violates the requirement that new features be behind a feature flag with the old behavior preserved when disabled.DOCX export is introduced/enabled without a feature flag and changes existing behavior (the Word button now downloads DOCX).
Compliance requires new features to be behind a flag, disabled by default, and to preserve the old execution path when disabled.
- src/static/js/pad_impexp.ts[147-164]
- src/node/hooks/express/importexport.ts[31-46]
| PR 7535 (2026-04-17) |
[correctness] Autofix breaks static HTML
Autofix breaks static HTML
In autofix mode, the new scan rewrites "/static/plugins/..." to "../static/plugins/..." in HTML files under a plugin’s `static/` directory, but those files are served from `/static/plugins//static/...` so the rewritten relative URL resolves to a non-existent path and will 404. The warning text also gives the same incorrect remediation guidance for `static/*.html` as for templates.checkPlugin currently applies the same autofix (/static/plugins/... -> ../static/plugins/...) to both templates/ and static/ HTML/EJS files. This is correct for templates rendered under pad pages, but incorrect for HTML served from a plugin’s static/ directory, because those pages are loaded from /static/plugins/<plugin>/static/... and need different relative paths.
Etherpad serves plugin static files through the /static/* handler (see Minify path rewriting). A one-size-fits-all rewrite to ../static/plugins/ will generate broken URLs for static HTML.
- bin/plugins/checkPlugin.ts[377-413]
- Keep scanning
static/**/*.htmlfor warnings, but either: -
Do not autofix files under
static/, and update the warning message to say “use a relative path appropriate for the file’s location (no leading '/')”, OR - Implement a separate autofix for
static/**/*.htmlthat computes the correct relative prefix to/static/plugins/based on the file’s depth under the plugin’sstatic/directory.
| PR 7516 (2026-04-14) |
[reliability] `ueberdb2` requires Node >=22
`ueberdb2` requires Node >=22
The updated `ueberdb2@5.0.42` dependency declares `node >=22.22.0`, but this repo declares `node >=20.0.0` with `engineStrict: true`, which will break installs/runs on Node 20. This is an incompatible change that must be avoided or explicitly documented with corresponding engine/version updates.ueberdb2@5.0.42 requires Node >=22.22.0 but this repo declares Node >=20.0.0 (and engineStrict: true), so the dependency bump can break installs/runs on Node 20.
The PR updates ueberdb2 to ^5.0.42. The lockfile indicates this version has a stricter Node engine requirement than the project currently declares.
- pnpm-lock.yaml[5038-5041]
- src/package.json[76-76]
- src/package.json[125-129]
- package.json[44-52]
| PR 7512 (2026-04-14) |
[maintainability] Wrong hook source path
Wrong hook source path
The new aceRegisterLineAttributes docs say the hook is called from `src/static/js/ace2_inner.js`, but the implementation is in `src/static/js/ace2_inner.ts`. This misleads readers trying to locate or verify the hook behavior in the source tree.The aceRegisterLineAttributes documentation claims the hook is called from src/static/js/ace2_inner.js, but the codebase uses src/static/js/ace2_inner.ts for the implementation. This makes it harder for readers to find the hook.
The hook is invoked in the Enter key handling logic via hooks.callAll('aceRegisterLineAttributes').
- doc/api/hooks_client-side.md[210-213]
- doc/api/hooks_client-side.adoc[214-217]
Replace src/static/js/ace2_inner.js with src/static/js/ace2_inner.ts in the new aceRegisterLineAttributes section (and keep the two doc formats consistent).
| PR 7491 (2026-04-07) |
[maintainability] Docs omit --otp flag
Docs omit --otp flag
`bin/setup-trusted-publishers.sh` now supports `--otp`, but `doc/npm-trusted-publishing.md` doesn’t document it in the script usage examples or explain the 2FA/EOTP flow. This can lead maintainers to follow the docs and hit EOTP without knowing the intended invocation.The repo documentation for npm trusted publishing doesn’t mention the new --otp option or the fact that npm trust github requires OTP on 2FA-enabled accounts.
The script itself now documents --otp and explains TOTP expiry, but maintainers are likely to follow doc/npm-trusted-publishing.md first.
- doc/npm-trusted-publishing.md[24-55]
- bin/setup-trusted-publishers.sh[17-22]
Update doc/npm-trusted-publishing.md to include:
- An example invocation with
--otp <code> - A short note that
npm trust githubrequires OTP for 2FA accounts and TOTPs expire quickly (suggest chunking with--packages).
| PR 7485 (2026-04-07) |
[correctness] Node version requirement mismatch
Node version requirement mismatch
bin/installer.sh accepts Node.js >=18 (and README advertises >=18), but the repo declares engines.node >=20, so the installer can proceed on Node 18/19 and then fail during dependency install/build.The installer and README currently state/enforce Node.js >=18, but the project’s package.json engines require Node.js >=20. This mismatch will cause the quick install flow to accept unsupported Node versions and then fail later.
-
bin/installer.shusesREQUIRED_NODE_MAJOR=18and checksNODE_MAJORagainst it. - The repo declares
engines.node: >=20.0.0(root and src).
- bin/installer.sh[34-51]
- README.md[46-54]
- README.md[126-138]
- Update the installer to require Node.js 20+ (and adjust messaging).
- Update README quick install and (optional but recommended) the Requirements section to explicitly mention Node.js >=20.
[correctness] Branch ignored for existing dir
Branch ignored for existing dir
If ETHERPAD_DIR already exists and is a git repo, the installer runs `git pull --ff-only` without checking out ETHERPAD_BRANCH or validating ETHERPAD_REPO, so it can silently install/build a different version than requested.When $ETHERPAD_DIR already exists, the installer does a plain git pull --ff-only and does not ensure the checkout matches $ETHERPAD_BRANCH (or that the remote matches $ETHERPAD_REPO). This can lead to silently building the wrong revision.
The fresh clone path uses git clone --branch "$ETHERPAD_BRANCH" "$ETHERPAD_REPO", but the existing repo path does not.
- bin/installer.sh[67-78]
- In the existing repo path:
- Validate the remote (e.g., compare
git remote get-url originto$ETHERPAD_REPO, or at least warn/fatal on mismatch). - Ensure a clean working tree (fatal if dirty).
- Fetch updates (
git fetch --tags --prune) and then checkout/switch to$ETHERPAD_BRANCH(or detach at tag) before pulling/fast-forwarding. - Optionally print which commit/branch was installed for clarity.
| PR 7480 (2026-04-06) |
[reliability] No loadTesting reproduction test
No loadTesting reproduction test
The added regression test verifies `CLIENT_VARS` consistency but does not enable `loadTesting` or simulate high edit rates during pad load, so it does not reproduce the reported "mismatched apply" failure mode under load. This fails the requirement to add automated load-test coverage for the specific edge case.The PR adds a regression test, but it does not reproduce the reported production failure mode under loadTesting (very active pads with concurrent edits while a client loads), as required.
The compliance requirement expects an automated scenario that can trigger the mismatched apply failure before the fix and demonstrate non-failure after the fix, specifically when loadTesting is enabled and edits occur at high frequency during pad load.
- src/tests/backend/specs/clientvar_rev_consistency.ts[23-51]
[reliability] No CLIENT_VARS delay test
No CLIENT_VARS delay test
The new test does not simulate or introduce any timing/delay or race between `CLIENT_VARS` delivery and subsequent `NEW_CHANGES` reception. This leaves the suspected race condition scenario untested.There is no test coverage that introduces a delay/race between receiving CLIENT_VARS and receiving/applying NEW_CHANGES, which is part of the reported failure mechanism.
The bug manifests when CLIENT_VARS state and subsequent revisions are out of sync due to timing; the compliance requirement expects an automated test that enforces this ordering/timing stress and asserts no mismatched apply occurs.
- src/tests/backend/specs/clientvar_rev_consistency.ts[36-52]
[correctness] Connect can miss revisions
Connect can miss revisions
In handleClientReady(), the server awaits the clientVars hook before socket.join(), so revisions created during that await are broadcast to existing room members but not to the connecting socket. Because there is no explicit catch-up after joining, the new client can remain stuck on an older revision until some later edit triggers updatePadClients().During handleClientReady() (first connect), the socket does not join the pad room until after await hooks.aCallAll('clientVars', ...). Any revisions appended while awaiting the hook are sent to existing room members via updatePadClients(), but the connecting socket is not yet in _getRoomSockets(pad.id) and therefore misses them. Because there is no explicit catch-up after joining, the new client can remain behind until a later edit occurs.
updatePadClients() is invoked when revisions are appended (including via other clients or API/import flows) and only targets sockets in the pad room.
- src/node/handler/PadMessageHandler.ts[1077-1093]
After socket.join(sessionInfo.padId) and after sending CLIENT_VARS (so the client is initialized), explicitly send any missing revisions:
- Initialize
sessionInfo.timeconsistently for the snapshot revision (for correcttimeDeltacomputation), ideally usingawait pad.getRevisionDate(headRev)(and consider also using this timestamp inclientVars.collab_client_vars.time). - Call
await exports.updatePadClients(pad)once aftersessionInfo.rev = headRev(and aftersessionInfo.timeis set) to flush any revisions that occurred between snapshot capture and the join. This ensures that if the pad advanced during the hook await window, the new client receivesNEW_CHANGESimmediately, even if the pad becomes quiet afterward.
[reliability] No loadTesting reproduction test
No loadTesting reproduction test
The added regression test verifies `CLIENT_VARS` consistency but does not enable `loadTesting` or simulate high edit rates during pad load, so it does not reproduce the reported "mismatched apply" failure mode under load. This fails the requirement to add automated load-test coverage for the specific edge case.The PR adds a regression test, but it does not reproduce the reported production failure mode under loadTesting (very active pads with concurrent edits while a client loads), as required.
The compliance requirement expects an automated scenario that can trigger the mismatched apply failure before the fix and demonstrate non-failure after the fix, specifically when loadTesting is enabled and edits occur at high frequency during pad load.
- src/tests/backend/specs/clientvar_rev_consistency.ts[23-51]
[reliability] No CLIENT_VARS delay test
No CLIENT_VARS delay test
The new test does not simulate or introduce any timing/delay or race between `CLIENT_VARS` delivery and subsequent `NEW_CHANGES` reception. This leaves the suspected race condition scenario untested.There is no test coverage that introduces a delay/race between receiving CLIENT_VARS and receiving/applying NEW_CHANGES, which is part of the reported failure mechanism.
The bug manifests when CLIENT_VARS state and subsequent revisions are out of sync due to timing; the compliance requirement expects an automated test that enforces this ordering/timing stress and asserts no mismatched apply occurs.
- src/tests/backend/specs/clientvar_rev_consistency.ts[36-52]
[correctness] Connect can miss revisions
Connect can miss revisions
In handleClientReady(), the server awaits the clientVars hook before socket.join(), so revisions created during that await are broadcast to existing room members but not to the connecting socket. Because there is no explicit catch-up after joining, the new client can remain stuck on an older revision until some later edit triggers updatePadClients().During handleClientReady() (first connect), the socket does not join the pad room until after await hooks.aCallAll('clientVars', ...). Any revisions appended while awaiting the hook are sent to existing room members via updatePadClients(), but the connecting socket is not yet in _getRoomSockets(pad.id) and therefore misses them. Because there is no explicit catch-up after joining, the new client can remain behind until a later edit occurs.
updatePadClients() is invoked when revisions are appended (including via other clients or API/import flows) and only targets sockets in the pad room.
- src/node/handler/PadMessageHandler.ts[1077-1093]
After socket.join(sessionInfo.padId) and after sending CLIENT_VARS (so the client is initialized), explicitly send any missing revisions:
- Initialize
sessionInfo.timeconsistently for the snapshot revision (for correcttimeDeltacomputation), ideally usingawait pad.getRevisionDate(headRev)(and consider also using this timestamp inclientVars.collab_client_vars.time). - Call
await exports.updatePadClients(pad)once aftersessionInfo.rev = headRev(and aftersessionInfo.timeis set) to flush any revisions that occurred between snapshot capture and the join. This ensures that if the pad advanced during the hook await window, the new client receivesNEW_CHANGESimmediately, even if the pad becomes quiet afterward.
[reliability] No loadTesting reproduction test
No loadTesting reproduction test
The added regression test verifies `CLIENT_VARS` consistency but does not enable `loadTesting` or simulate high edit rates during pad load, so it does not reproduce the reported "mismatched apply" failure mode under load. This fails the requirement to add automated load-test coverage for the specific edge case.The PR adds a regression test, but it does not reproduce the reported production failure mode under loadTesting (very active pads with concurrent edits while a client loads), as required.
The compliance requirement expects an automated scenario that can trigger the mismatched apply failure before the fix and demonstrate non-failure after the fix, specifically when loadTesting is enabled and edits occur at high frequency during pad load.
- src/tests/backend/specs/clientvar_rev_consistency.ts[23-51]
[reliability] No CLIENT_VARS delay test
No CLIENT_VARS delay test
The new test does not simulate or introduce any timing/delay or race between `CLIENT_VARS` delivery and subsequent `NEW_CHANGES` reception. This leaves the suspected race condition scenario untested.There is no test coverage that introduces a delay/race between receiving CLIENT_VARS and receiving/applying NEW_CHANGES, which is part of the reported failure mechanism.
The bug manifests when CLIENT_VARS state and subsequent revisions are out of sync due to timing; the compliance requirement expects an automated test that enforces this ordering/timing stress and asserts no mismatched apply occurs.
- src/tests/backend/specs/clientvar_rev_consistency.ts[36-52]
[correctness] Connect can miss revisions
Connect can miss revisions
In handleClientReady(), the server awaits the clientVars hook before socket.join(), so revisions created during that await are broadcast to existing room members but not to the connecting socket. Because there is no explicit catch-up after joining, the new client can remain stuck on an older revision until some later edit triggers updatePadClients().During handleClientReady() (first connect), the socket does not join the pad room until after await hooks.aCallAll('clientVars', ...). Any revisions appended while awaiting the hook are sent to existing room members via updatePadClients(), but the connecting socket is not yet in _getRoomSockets(pad.id) and therefore misses them. Because there is no explicit catch-up after joining, the new client can remain behind until a later edit occurs.
updatePadClients() is invoked when revisions are appended (including via other clients or API/import flows) and only targets sockets in the pad room.
- src/node/handler/PadMessageHandler.ts[1077-1093]
After socket.join(sessionInfo.padId) and after sending CLIENT_VARS (so the client is initialized), explicitly send any missing revisions:
- Initialize
sessionInfo.timeconsistently for the snapshot revision (for correcttimeDeltacomputation), ideally usingawait pad.getRevisionDate(headRev)(and consider also using this timestamp inclientVars.collab_client_vars.time). - Call
await exports.updatePadClients(pad)once aftersessionInfo.rev = headRev(and aftersessionInfo.timeis set) to flush any revisions that occurred between snapshot capture and the join. This ensures that if the pad advanced during the hook await window, the new client receivesNEW_CHANGESimmediately, even if the pad becomes quiet afterward.
[reliability] No loadTesting reproduction test
No loadTesting reproduction test
The added regression test verifies `CLIENT_VARS` consistency but does not enable `loadTesting` or simulate high edit rates during pad load, so it does not reproduce the reported "mismatched apply" failure mode under load. This fails the requirement to add automated load-test coverage for the specific edge case.The PR adds a regression test, but it does not reproduce the reported production failure mode under loadTesting (very active pads with concurrent edits while a client loads), as required.
The compliance requirement expects an automated scenario that can trigger the mismatched apply failure before the fix and demonstrate non-failure after the fix, specifically when loadTesting is enabled and edits occur at high frequency during pad load.
- src/tests/backend/specs/clientvar_rev_consistency.ts[23-51]
[reliability] No CLIENT_VARS delay test
No CLIENT_VARS delay test
The new test does not simulate or introduce any timing/delay or race between `CLIENT_VARS` delivery and subsequent `NEW_CHANGES` reception. This leaves the suspected race condition scenario untested.There is no test coverage that introduces a delay/race between receiving CLIENT_VARS and receiving/applying NEW_CHANGES, which is part of the reported failure mechanism.
The bug manifests when CLIENT_VARS state and subsequent revisions are out of sync due to timing; the compliance requirement expects an automated test that enforces this ordering/timing stress and asserts no mismatched apply occurs.
- src/tests/backend/specs/clientvar_rev_consistency.ts[36-52]
[correctness] Connect can miss revisions
Connect can miss revisions
In handleClientReady(), the server awaits the clientVars hook before socket.join(), so revisions created during that await are broadcast to existing room members but not to the connecting socket. Because there is no explicit catch-up after joining, the new client can remain stuck on an older revision until some later edit triggers updatePadClients().During handleClientReady() (first connect), the socket does not join the pad room until after await hooks.aCallAll('clientVars', ...). Any revisions appended while awaiting the hook are sent to existing room members via updatePadClients(), but the connecting socket is not yet in _getRoomSockets(pad.id) and therefore misses them. Because there is no explicit catch-up after joining, the new client can remain behind until a later edit occurs.
updatePadClients() is invoked when revisions are appended (including via other clients or API/import flows) and only targets sockets in the pad room.
- src/node/handler/PadMessageHandler.ts[1077-1093]
After socket.join(sessionInfo.padId) and after sending CLIENT_VARS (so the client is initialized), explicitly send any missing revisions:
- Initialize
sessionInfo.timeconsistently for the snapshot revision (for correcttimeDeltacomputation), ideally usingawait pad.getRevisionDate(headRev)(and consider also using this timestamp inclientVars.collab_client_vars.time). - Call
await exports.updatePadClients(pad)once aftersessionInfo.rev = headRev(and aftersessionInfo.timeis set) to flush any revisions that occurred between snapshot capture and the join. This ensures that if the pad advanced during the hook await window, the new client receivesNEW_CHANGESimmediately, even if the pad becomes quiet afterward.
[maintainability] Test doesn't validate rev
Test doesn't validate rev
The new regression test only compares initialAttributedText.text to pad.text() and never verifies that initialAttributedText corresponds to collabVars.rev. This can pass even if CLIENT_VARS advertises an incorrect revision number (the invariant that matters for preventing mismatched apply).The regression test claims to verify that collabVars.rev matches initialAttributedText, but it only checks initialAttributedText.text === pad.text(). This does not assert the core invariant (text corresponds to the advertised revision) and can miss the exact class of bug being guarded.
Pad.getInternalRevisionAText(rev) exists and can compute the AText for an arbitrary revision.
- src/tests/backend/specs/clientvar_rev_consistency.ts[23-55]
- src/node/db/Pad.ts[209-222]
In the test, replace (or supplement) the pad.text() comparison with a revision-specific comparison:
- After receiving
CLIENT_VARS, fetchconst atextAtRev = await pad.getInternalRevisionAText(collabVars.rev); - Assert
atextAtRev.text === collabVars.initialAttributedText.textOptionally, to better exercise the original race, introduce a concurrent edit while the handshake is in progress (e.g., starthandshake()but do not await it immediately, perform apad.setText()or append revision, then await the handshake) and still assert consistency usinggetInternalRevisionAText(collabVars.rev)(do not assert it equals the latest head text in the presence of concurrency).
[correctness] NaN timeDelta in catch-up
NaN timeDelta in catch-up
handleClientReady now calls updatePadClients() right after sending CLIENT_VARS, so a newly connected socket can immediately receive NEW_CHANGES catch-up messages. updatePadClients computes timeDelta as currentTime - sessioninfo.time, but sessioninfo.time is never initialized for a new session, resulting in timeDelta=NaN which breaks timeslider/broadcast code that does currentTime += timeDelta.updatePadClients() computes timeDelta as currentTime - sessioninfo.time. In the new initial-connect catch-up path (handleClientReady now calls await updatePadClients(pad)), sessioninfo.time can be undefined, producing timeDelta: NaN in NEW_CHANGES. Timeslider/broadcast code uses timeDelta to advance currentTime, so NaN will break time rendering.
A new socket’s sessioninfos[socket.id] is initialized as {} and handleClientReady sets sessionInfo.rev but does not set sessionInfo.time before calling updatePadClients().
- src/node/handler/PadMessageHandler.ts[1088-1097]
- src/node/handler/PadMessageHandler.ts[738-795]
Pick one (or both):
- Initialize
sessionInfo.timeduring CLIENT_READY handling (normal connect and reconnect) to the timestamp that corresponds tosessionInfo.rev(oftencurrentTimealready computed for CLIENT_VARS). - Make
updatePadClients()robust:
- If
sessioninfo.timeis nullish, settimeDeltato0(or compute based on the revision timestamp) and/or initializesessioninfo.timebefore computingtimeDelta.
| PR 7474 (2026-04-06) |
[security] 10MB socket payload exposure
10MB socket payload exposure
Raising the default `socketIo.maxHttpBufferSize` to 10MB increases the amount of data an (often unauthenticated) client can force the server to parse and route per socket.io message. The existing production rate limiter is per-message (not per-byte), so large 10MB messages are treated the same as tiny messages, increasing DoS/performance risk.With maxHttpBufferSize defaulting to 10MB, the current per-message socket rate limiting allows very large payloads at the same event cost as small ones, increasing resource exhaustion risk.
- Socket.io transport accepts up to
settings.socketIo.maxHttpBufferSizebytes per message. -
PadMessageHandler.handleMessage()applies rate limiting only in production and always consumes 1 point per event.
- src/node/handler/PadMessageHandler.ts[273-286]
- src/node/utils/Settings.ts[619-632]
- src/node/utils/Settings.ts[360-370]
Adjust the rate limiting consumption to scale with payload size for message types that can be large (notably COLLABROOM/USER_CHANGES). For example:
- Estimate bytes using a cheap field (e.g.,
message.data?.changeset?.lengthwhen present). - Consume
Math.max(1, Math.ceil(bytes / (1024 * 1024)))points so ~1MiB costs 1 point; a 10MB paste costs ~10 points. This keeps single large pastes possible while reducing the ability to flood the server with repeated max-sized messages.
| PR 7472 (2026-04-06) |
[reliability] Dev proxy test is conditional
Dev proxy test is conditional
The new `x-proxy-path` test only asserts when it detects a dev-mode `/watch/` script URL, but the backend test runner sets `NODE_ENV=production`, so the assertion is skipped and the test can pass even if the fix is reverted. This violates the requirement to include a regression test that deterministically fails without the fix.The new x-proxy-path regression test can be a no-op because it only asserts in dev mode, while backend tests are executed with NODE_ENV=production.
src/package.json runs backend tests with cross-env NODE_ENV=production ..., so specialpages.ts uses the production branch that emits relative entrypoints (no /watch/), causing the new test's assertion to be skipped.
- src/tests/backend/specs/specialpages.ts[35-62]
- src/package.json[134-140]
- src/node/hooks/express/specialpages.ts[312-339]
[security] Unvalidated proxy path header
Unvalidated proxy path header
In dev mode, `handleLiveReload()` concatenates the request-supplied `x-proxy-path` header into the JavaScript entrypoint URL, so malformed values (e.g., `'/'` → `//watch/...`) or scheme/protocol-relative prefixes can produce an off-origin `` or broken asset path. Because `entrypoint` is rendered into `">`, this directly controls what JavaScript the browser attempts to load.x-proxy-path is used directly to build <script src> URLs in dev mode. Certain header values can generate protocol-relative URLs (e.g., '/' + '/watch/..' → //watch/..) or other unexpected paths; additionally, accepting arbitrary prefixes allows off-origin script URLs if the prefix contains // or a scheme.
This is in the dev-mode live reload path (handleLiveReload). Production mode uses relative entrypoints and is unaffected.
- Add a small helper to normalize/validate
x-proxy-path(e.g., allow only''or a path that starts with a single/, strip trailing/, reject values starting with//or containing:). - Use the helper for all three dev-mode entrypoints (index/pad/timeslider).
- src/node/hooks/express/specialpages.ts[161-170]
- src/node/hooks/express/specialpages.ts[182-199]
- src/node/hooks/express/specialpages.ts[212-230]
| PR 7470 (2026-04-05) |
[correctness] Explicit start overridden
Explicit start overridden
When opening a new ordered list, the counter-based continuation takes precedence over `line.start`, so an explicitly stored start value can be ignored and replaced with a computed value. This can export incorrect numbering for lists that intentionally restart or that were imported with explicit `` semantics.The HTML exporter opens a new <ol> using olItemCounts before honoring line.start, which means explicit list starts (from stored attributes or HTML import) can be overwritten.
line.start comes from the start line attribute in the pad’s attribution stream. The editor renumbering logic sets this attribute intentionally, so export should treat it as authoritative when present.
- src/node/utils/ExportHtml.ts[393-402]
- src/node/utils/ExportHtml.ts[414-423]
- When deciding the
<ol ...>opener: - If
line.startis present and is a finite number, emit that value. - Else fall back to the continuation counter.
- Ensure the counter is seeded/aligned when
line.startis used (so later continuations at that level don’t assume the list started at 1).
[correctness] No per-level counter reset
No per-level counter reset
`olItemCounts` is only cleared when leaving all lists, not when a list level is closed (listLevel decreases). This can incorrectly continue numbering when re-entering the same indentation level later (common with nested ordered lists).The new olItemCounts map is cleared only when line.listLevel becomes falsy (outside any list). When the exporter closes list levels due to indentation decreasing, counters for the closed levels remain and can incorrectly affect later <ol start="..."> values.
Nested lists commonly decrease listLevel back to a parent and later re-enter the same nested level under a different parent item. Counters for that nested level should not carry across these separate nested list instances unless the document’s stored start attributes explicitly indicate continuation.
- src/node/utils/ExportHtml.ts[441-466]
- src/node/utils/ExportHtml.ts[468-472]
- When closing list elements (the branch that emits
</ol>/</ul>whennextLine.listLevel < line.listLevelor list type changes): - Delete
olItemCounts[level]entries for any ordered-list levels being closed. - Keep counts only for still-open levels.
- Keep the existing full reset when leaving all lists as a safety net.
[maintainability] Brittle start assertions
Brittle start assertions
The new tests use `html.includes('start="N"')`, which is less specific than the previous regex-based checks and can accidentally pass if an unrelated list segment contains the same start value. This weakens regression protection as list export grows more complex (e.g., nested lists).The tests match start="2" / start="3" anywhere in the HTML, which is not tied to the specific ordered-list segment that should continue numbering.
As export output evolves (nested lists, multiple ordered lists in one pad), a global substring match can yield false positives.
- src/tests/backend/specs/export_list.ts[36-104]
- Extract ordered-list open tags in order (e.g., regex for
<ol[^>]*class="number"[^>]*>), then assert on the second segment’s attributes. - Or parse the HTML with jsdom and query for
ol.numbernodes, asserting thestartattribute on the expected index.
| PR 7469 (2026-04-05) |
[reliability] `.git` copy still mandatory
`.git` copy still mandatory
The new `ONBUILD COPY --chown=etherpad:etherpad ./.git ./.git` still hard-requires a `.git` entry to exist in the build context, so builds from contexts without git metadata (e.g., source tarballs) will still fail at the COPY step. This conflicts with the requirement to support deterministic builds when `.git` is unavailable by providing a bypass/override mechanism.The Docker build still unconditionally requires .git to be present in the build context due to ONBUILD COPY ./.git ./.git, which breaks builds where git metadata is not included (and provides no explicit deterministic override path).
COPY cannot be conditional, so a missing .git causes an immediate build failure. The compliance requirement expects a documented bypass (build-arg/env) for version/commit discovery when .git is unavailable.
- Dockerfile[6-6]
- Dockerfile[119-134]
- .dockerignore[6-22]
| PR 7468 (2026-04-05) |
[correctness] Wrong pnpm project scope
Wrong pnpm project scope
`bin/updatePlugins.sh` runs `pnpm outdated` / `pnpm update` from the workspace root, but the root `package.json` only depends on the linked `ep_etherpad-lite` package, so this script will not detect/update plugins that are dependencies of the main `src` package. As a result it can incorrectly report “All plugins are up-to-date” even when plugins used by Etherpad are outdated.updatePlugins.sh runs pnpm at the workspace root, which (in this repo) does not list plugin dependencies, so pnpm outdated won’t surface outdated plugins used by the main app.
- Workspace root
package.jsononly depends onep_etherpad-lite(link). - Other repo scripts scope pnpm operations via
--filteror--recursive.
- bin/updatePlugins.sh[3-11]
Update the script to run against the ep_etherpad-lite workspace project (or cd src before running pnpm), e.g.:
pnpm --filter ep_etherpad-lite outdated --depth=0 ...-
pnpm --filter ep_etherpad-lite update "$@"(keeping the rest of the logic the same).
[correctness] Updates core package
Updates core package
The `grep '^ep_'` filter can include `ep_etherpad-lite`, which other plugin tooling explicitly excludes; attempting to update the core workspace-linked package is not a plugin update and can fail or block updating real plugins. This makes the update list incorrect and can cause the script to behave unexpectedly.updatePlugins.sh collects package names matching ^ep_ but doesn’t exclude ep_etherpad-lite, which is not a plugin and is explicitly skipped elsewhere.
Other plugin management/update code filters out ep_etherpad-lite.
- bin/updatePlugins.sh[5-5]
Add an exclusion after the grep '^ep_' step, for example:
-
... | grep '^ep_' | grep -v '^ep_etherpad-lite$'(If you also implement workspace scoping, keep this exclusion as a safety guard.)
[reliability] Errors reported as up-to-date
Errors reported as up-to-date
Any failure in `pnpm outdated` (for example, pnpm execution errors) is treated the same as “no outdated plugins” because the `|| { echo "All plugins are up-to-date"; exit 0; }` handler triggers on any non-zero pipeline exit. This can silently hide real problems and cause false success.The script currently prints "All plugins are up-to-date" for any non-zero exit status from the pnpm outdated | awk | grep pipeline, which includes actual command failures.
POSIX /bin/sh does not reliably support set -o pipefail, so the fix should avoid relying on pipefail.
- bin/updatePlugins.sh[5-8]
Restructure to:
- Run
pnpm ... outdatedand fail hard if it fails. - Then parse the output; treat
grepno-match as an empty list. Example structure:
| PR 7467 (2026-04-05) |
[correctness] Map updated after failure
Map updated after failure
addSubDependency() updates dependenciesMap even when linking or package.json read/parse fails, marking the dependency as tracked despite an incomplete/failed setup. This can block later cleanup because removeSubDependency() returns early when dependenciesMap.has(dependency) is true.addSubDependency() calls this.dependenciesMap.set(dependency, ...) even if the try block fails (linking or reading/parsing package.json). This can leave the dependency marked as “in use” and prevent removal later.
removeSubDependency() returns early if dependenciesMap.has(dependency) is true, so an entry created after a failed add can block cleanup.
- src/static/js/pluginfw/LinkInstaller.ts[180-200]
- src/static/js/pluginfw/LinkInstaller.ts[94-110]
- Move
this.dependenciesMap.set(dependency, new Set([plugin]))into the try block after successful link + successfulpackage.jsonread/parse. - If
package.jsonread/parse fails, do not set the map entry; optionally rethrow so plugin install fails loudly rather than leaving partial state. - (Optional hardening) After
linkDependency(), verify the symlink/dir exists before marking success.
| PR 7465 (2026-04-05) |
[maintainability] Docker docs mismatch
Docker docs mismatch
The PR updates doc/docker.md to recommend pulling image tag 2.6.1, but the parallel AsciiDoc doc/docker.adoc still recommends 1.8.0, resulting in conflicting installation instructions. Users following the AsciiDoc guide will likely deploy an outdated version.doc/docker.md was updated to reference Docker image tag 2.6.1, but the AsciiDoc equivalent (doc/docker.adoc) still references 1.8.0, creating conflicting guidance.
The repository's current version is 2.6.1, and the Markdown Docker docs already reflect that.
- doc/docker.adoc[10-16]
| PR 7464 (2026-04-05) |
[correctness] RTL preference forced to false
RTL preference forced to false
`pad._afterHandshake()` now always calls `changeViewOption('rtlIsTrue', false)` for the common case where `settings.rtlIsTrue` is false, which overwrites any existing `rtlIsTrue` cookie and forces LTR even when RTL should come from cookie or language direction. This can break persisted RTL mode and prevent RTL-by-language defaults from taking effect unless the URL explicitly enables RTL.pad._afterHandshake() now unconditionally calls changeViewOption('rtlIsTrue', settings.rtlIsTrue === true). Because settings.rtlIsTrue is typically false (server defaults include padOptions.rtl: false), this writes rtlIsTrue=false into view options and persists it to the prefs cookie, overwriting an existing rtlIsTrue=true cookie and suppressing RTL-by-language defaults.
-
changeViewOption()flows intohandleOptionsChange(), which persists view options to the prefs cookie viapadcookie.setPref(). - Cookie restore logic for RTL only re-applies when the cookie is
true, so overwriting it tofalseprevents restore.
- Only apply
rtlIsTrueview option when there is an explicit override (e.g., URL containsrtl, or a server config explicitly enables RTL). Avoid callingchangeViewOption('rtlIsTrue', false)as a default. - Consider using presence checks (
URLSearchParams.has('rtl')) or a tri-state/flag to distinguish “unset” from “explicit false”.
- In
_afterHandshake(), replace the unconditional call with logic like:
- If
getUrlVars().has('rtl'), setrtlIsTruebased on the URL value (params.get('rtl') === 'true'). - Else if server config explicitly sets RTL on (e.g.,
clientVars.padOptions.rtl === true), set it totrue. - Else do nothing so cookie and
html10n.getDirection()defaults can apply.
- src/static/js/pad.ts[153-173]
- src/static/js/pad.ts[429-556]
- src/static/js/pad.ts[598-617]
[correctness] RTL preference forced to false
RTL preference forced to false
`pad._afterHandshake()` now always calls `changeViewOption('rtlIsTrue', false)` for the common case where `settings.rtlIsTrue` is false, which overwrites any existing `rtlIsTrue` cookie and forces LTR even when RTL should come from cookie or language direction. This can break persisted RTL mode and prevent RTL-by-language defaults from taking effect unless the URL explicitly enables RTL.pad._afterHandshake() now unconditionally calls changeViewOption('rtlIsTrue', settings.rtlIsTrue === true). Because settings.rtlIsTrue is typically false (server defaults include padOptions.rtl: false), this writes rtlIsTrue=false into view options and persists it to the prefs cookie, overwriting an existing rtlIsTrue=true cookie and suppressing RTL-by-language defaults.
-
changeViewOption()flows intohandleOptionsChange(), which persists view options to the prefs cookie viapadcookie.setPref(). - Cookie restore logic for RTL only re-applies when the cookie is
true, so overwriting it tofalseprevents restore.
- Only apply
rtlIsTrueview option when there is an explicit override (e.g., URL containsrtl, or a server config explicitly enables RTL). Avoid callingchangeViewOption('rtlIsTrue', false)as a default. - Consider using presence checks (
URLSearchParams.has('rtl')) or a tri-state/flag to distinguish “unset” from “explicit false”.
- In
_afterHandshake(), replace the unconditional call with logic like:
- If
getUrlVars().has('rtl'), setrtlIsTruebased on the URL value (params.get('rtl') === 'true'). - Else if server config explicitly sets RTL on (e.g.,
clientVars.padOptions.rtl === true), set it totrue. - Else do nothing so cookie and
html10n.getDirection()defaults can apply.
- src/static/js/pad.ts[153-173]
- src/static/js/pad.ts[429-556]
- src/static/js/pad.ts[598-617]
[correctness] RTL preference forced to false
RTL preference forced to false
`pad._afterHandshake()` now always calls `changeViewOption('rtlIsTrue', false)` for the common case where `settings.rtlIsTrue` is false, which overwrites any existing `rtlIsTrue` cookie and forces LTR even when RTL should come from cookie or language direction. This can break persisted RTL mode and prevent RTL-by-language defaults from taking effect unless the URL explicitly enables RTL.pad._afterHandshake() now unconditionally calls changeViewOption('rtlIsTrue', settings.rtlIsTrue === true). Because settings.rtlIsTrue is typically false (server defaults include padOptions.rtl: false), this writes rtlIsTrue=false into view options and persists it to the prefs cookie, overwriting an existing rtlIsTrue=true cookie and suppressing RTL-by-language defaults.
-
changeViewOption()flows intohandleOptionsChange(), which persists view options to the prefs cookie viapadcookie.setPref(). - Cookie restore logic for RTL only re-applies when the cookie is
true, so overwriting it tofalseprevents restore.
- Only apply
rtlIsTrueview option when there is an explicit override (e.g., URL containsrtl, or a server config explicitly enables RTL). Avoid callingchangeViewOption('rtlIsTrue', false)as a default. - Consider using presence checks (
URLSearchParams.has('rtl')) or a tri-state/flag to distinguish “unset” from “explicit false”.
- In
_afterHandshake(), replace the unconditional call with logic like:
- If
getUrlVars().has('rtl'), setrtlIsTruebased on the URL value (params.get('rtl') === 'true'). - Else if server config explicitly sets RTL on (e.g.,
clientVars.padOptions.rtl === true), set it totrue. - Else do nothing so cookie and
html10n.getDirection()defaults can apply.
- src/static/js/pad.ts[153-173]
- src/static/js/pad.ts[429-556]
- src/static/js/pad.ts[598-617]
[correctness] RTL preference forced to false
RTL preference forced to false
`pad._afterHandshake()` now always calls `changeViewOption('rtlIsTrue', false)` for the common case where `settings.rtlIsTrue` is false, which overwrites any existing `rtlIsTrue` cookie and forces LTR even when RTL should come from cookie or language direction. This can break persisted RTL mode and prevent RTL-by-language defaults from taking effect unless the URL explicitly enables RTL.pad._afterHandshake() now unconditionally calls changeViewOption('rtlIsTrue', settings.rtlIsTrue === true). Because settings.rtlIsTrue is typically false (server defaults include padOptions.rtl: false), this writes rtlIsTrue=false into view options and persists it to the prefs cookie, overwriting an existing rtlIsTrue=true cookie and suppressing RTL-by-language defaults.
-
changeViewOption()flows intohandleOptionsChange(), which persists view options to the prefs cookie viapadcookie.setPref(). - Cookie restore logic for RTL only re-applies when the cookie is
true, so overwriting it tofalseprevents restore.
- Only apply
rtlIsTrueview option when there is an explicit override (e.g., URL containsrtl, or a server config explicitly enables RTL). Avoid callingchangeViewOption('rtlIsTrue', false)as a default. - Consider using presence checks (
URLSearchParams.has('rtl')) or a tri-state/flag to distinguish “unset” from “explicit false”.
- In
_afterHandshake(), replace the unconditional call with logic like:
- If
getUrlVars().has('rtl'), setrtlIsTruebased on the URL value (params.get('rtl') === 'true'). - Else if server config explicitly sets RTL on (e.g.,
clientVars.padOptions.rtl === true), set it totrue. - Else do nothing so cookie and
html10n.getDirection()defaults can apply.
- src/static/js/pad.ts[153-173]
- src/static/js/pad.ts[429-556]
- src/static/js/pad.ts[598-617]
| PR 7461 (2026-04-04) |
[correctness] Doesn't capture no-list
Doesn't capture no-list
Neighbor list state is only saved if getLineListType() returns a truthy string, so lines with no list attribute are ignored. If corruption adds a list attribute to a previously non-list neighbor line, there is no saved baseline to detect and revert the corruption.The pre-drop snapshot only records neighbor list types when they are truthy, which drops the case where the neighbor line has no list attribute. If the browser corruption adds a list type, the code cannot restore back to “no list”.
setLineListType(lineNum, '') is the supported way to clear list state (and related metadata).
- src/static/js/ace2_inner.ts[3269-3286]
- src/static/js/ace2_inner.ts[2353-2359]
- Always push a snapshot entry for the neighbor lines, even when
getLineListType(...)is null/undefined. - Use a type like
listType: string | null(orundefined) in the snapshot. - During restore: if saved is null/undefined and current is non-null, clear via
setLineListType(resolvedLineNum, '')(or removeAttributeOnLine).
[correctness] Skips list normalization
Skips list normalization
dropRestore restores list state via documentAttributeManager.setAttributeOnLine('list', ...) instead of setLineListType(), bypassing ordered-list renumbering and 'start' cleanup. This can leave ordered list numbering or related line metadata inconsistent after restoration.The restoration logic writes the 'list' attribute directly with setAttributeOnLine(), which bypasses list-specific invariants (clearing 'start', renumbering ordered lists).
setLineListType() is the established helper that maintains list-related attributes and calls renumberList().
- src/static/js/ace2_inner.ts[3308-3313]
- src/static/js/ace2_inner.ts[2353-2367]
- Replace
documentAttributeManager.setAttributeOnLine(lineNum, 'list', listType)withsetLineListType(resolvedLineNum, listType ?? ''). - Ensure the snapshot/restoration logic can represent the cleared state (no list) so
setLineListType(..., '')is used when appropriate.
[reliability] atKey may throw
atKey may throw
The new drop handler calls rep.lines.atKey(firstLineSelected.id) / atKey(lastLineSelected.id) without checking that those IDs exist in rep.lines. SkipList.atKey() dereferences with a non-null assertion and will throw if the key is missing, breaking drag-and-drop with an uncaught exception.rep.lines.atKey(...) can throw at runtime if the DOM node id is missing or not present in rep.lines. This can crash the drop handler.
SkipList.atKey() uses a non-null assertion on the backing map lookup, so missing keys are fatal.
- src/static/js/ace2_inner.ts[3269-3272]
- src/static/js/skiplist.ts[332-335]
- Before calling
rep.lines.atKey(firstLineSelected.id)/rep.lines.atKey(lastLineSelected.id), validate:
firstLineSelected != null && typeof firstLineSelected.id === 'string' && firstLineSelected.id !== ''-
rep.lines.containsKey(firstLineSelected.id)(same for last).
- If validation fails, set
savedLineAttrs = null(or leave it null) and skip the restore path. - (Optional) If you switch to saving neighbor keys (recommended), you can avoid
atKey()here entirely and only usecontainsKey/indexOfKeylater.
[correctness] Doesn't capture no-list
Doesn't capture no-list
Neighbor list state is only saved if getLineListType() returns a truthy string, so lines with no list attribute are ignored. If corruption adds a list attribute to a previously non-list neighbor line, there is no saved baseline to detect and revert the corruption.The pre-drop snapshot only records neighbor list types when they are truthy, which drops the case where the neighbor line has no list attribute. If the browser corruption adds a list type, the code cannot restore back to “no list”.
setLineListType(lineNum, '') is the supported way to clear list state (and related metadata).
- src/static/js/ace2_inner.ts[3269-3286]
- src/static/js/ace2_inner.ts[2353-2359]
- Always push a snapshot entry for the neighbor lines, even when
getLineListType(...)is null/undefined. - Use a type like
listType: string | null(orundefined) in the snapshot. - During restore: if saved is null/undefined and current is non-null, clear via
setLineListType(resolvedLineNum, '')(or removeAttributeOnLine).
[correctness] Skips list normalization
Skips list normalization
dropRestore restores list state via documentAttributeManager.setAttributeOnLine('list', ...) instead of setLineListType(), bypassing ordered-list renumbering and 'start' cleanup. This can leave ordered list numbering or related line metadata inconsistent after restoration.The restoration logic writes the 'list' attribute directly with setAttributeOnLine(), which bypasses list-specific invariants (clearing 'start', renumbering ordered lists).
setLineListType() is the established helper that maintains list-related attributes and calls renumberList().
- src/static/js/ace2_inner.ts[3308-3313]
- src/static/js/ace2_inner.ts[2353-2367]
- Replace
documentAttributeManager.setAttributeOnLine(lineNum, 'list', listType)withsetLineListType(resolvedLineNum, listType ?? ''). - Ensure the snapshot/restoration logic can represent the cleared state (no list) so
setLineListType(..., '')is used when appropriate.
| PR 7460 (2026-04-04) |
[security] Unsanitized HTML paste insertion
Unsanitized HTML paste insertion
The new paste handler parses `text/html` from the clipboard and directly imports/inserts the resulting DOM nodes into the editor document without sanitization, allowing scriptable elements/attributes from untrusted clipboard HTML to enter the live DOM. Because incorporation is deferred via `setTimeout(…, 0)`, malicious nodes can execute (or trigger loads) before Etherpad’s collector later ignores unsupported tags.The paste handler inserts clipboard-provided HTML nodes into the live editor DOM without sanitizing tags/attributes, enabling XSS via event handlers/scriptable elements and unwanted resource loads.
The intent is to preserve inline formatting tags (b/i/u/s/etc.) that the browser otherwise normalizes away. The fix should preserve those formatting tags but must reject/strip dangerous markup and attributes before insertion.
- src/static/js/ace2_inner.ts[3244-3275]
- Build a strict allowlist sanitizer for the parsed clipboard DOM before
importNode(): - Allow only a minimal set of elements needed for formatting/structure (for example:
b,strong,i,em,u,s,del,ins,br, and optionallydiv/p/span). - Strip all attributes (especially
on*,style,src,href, etc.). - Drop disallowed elements entirely (or unwrap them, keeping safe child text/formatting).
- Insert only the sanitized fragment into the range.
- (Optional hardening) If sanitization results in empty content, fall back to default paste behavior.
[security] Unsanitized HTML paste insertion
Unsanitized HTML paste insertion
The new paste handler parses `text/html` from the clipboard and directly imports/inserts the resulting DOM nodes into the editor document without sanitization, allowing scriptable elements/attributes from untrusted clipboard HTML to enter the live DOM. Because incorporation is deferred via `setTimeout(…, 0)`, malicious nodes can execute (or trigger loads) before Etherpad’s collector later ignores unsupported tags.The paste handler inserts clipboard-provided HTML nodes into the live editor DOM without sanitizing tags/attributes, enabling XSS via event handlers/scriptable elements and unwanted resource loads.
The intent is to preserve inline formatting tags (b/i/u/s/etc.) that the browser otherwise normalizes away. The fix should preserve those formatting tags but must reject/strip dangerous markup and attributes before insertion.
- src/static/js/ace2_inner.ts[3244-3275]
- Build a strict allowlist sanitizer for the parsed clipboard DOM before
importNode(): - Allow only a minimal set of elements needed for formatting/structure (for example:
b,strong,i,em,u,s,del,ins,br, and optionallydiv/p/span). - Strip all attributes (especially
on*,style,src,href, etc.). - Drop disallowed elements entirely (or unwrap them, keeping safe child text/formatting).
- Insert only the sanitized fragment into the range.
- (Optional hardening) If sanitization results in empty content, fall back to default paste behavior.
| PR 7455 (2026-04-04) |
[security] JSON primitive POST hangs
JSON primitive POST hangs
In `openapi.ts`, POST requests fall back to `formidable.parse(req)` unless `req.body` is an object, so a valid `application/json` body that parses to a primitive (e.g., `"x"`, `123`, `true`) will still hit formidable after `express.json()` has consumed the stream, reintroducing the hang/timeout. Because parsing happens before auth checks, an unauthenticated request can trigger this and tie up server resources.src/node/hooks/express/openapi.ts falls back to formidable.parse(req) unless req.body is an object. For Content-Type: application/json requests where Express parses the body to a primitive (string/number/boolean), the code will still call formidable on an already-consumed stream and hang.
express.json() is installed globally, so JSON requests will have their streams consumed before this handler runs.
- src/node/hooks/express/openapi.ts[613-630]
- Detect JSON requests via
req.headers['content-type']/req.is('application/json')(or equivalently: ifreq.bodyis notundefinedfor POST) and never invoke formidable for those. - For JSON requests where
req.bodyis not a plain object (primitive/array), setformDatato{}(or otherwise map it safely) rather than passing the primitive/array intoObject.assign(). - Keep formidable parsing only for
multipart/form-dataandapplication/x-www-form-urlencoded(or when the body stream has not been consumed).
[reliability] No POST JSON regression test
No POST JSON regression test
The PR changes POST body parsing behavior for legacy API OpenAPI handling but does not add an automated regression test that would fail without this fix. This increases the risk that POST+JSON timeouts (or incorrect method handling) reappear unnoticed in future changes.The OpenAPI legacy API POST parsing fix is not protected by a regression test that exercises a legacy v1.2.14 endpoint via POST with Content-Type: application/json.
The PR modifies openapi.ts to avoid hangs/timeouts when express.json() has already consumed the request stream, and to make method handling case-insensitive. Without a targeted test, future refactors could reintroduce the timeout/incorrect parsing.
- src/tests/backend/specs/api/sessionsAndGroups.ts[257-266]
- src/node/hooks/express/openapi.ts[613-630]
[reliability] Empty JSON still times out
Empty JSON still times out
In src/node/hooks/express/openapi.ts, JSON POST requests with an empty parsed body (e.g., '{}' or '[]') still fall back to formidable.parse(req), which can hang because express.json() has already consumed the request stream. This leaves a reliable timeout/DoS edge case where requests remain stuck until the server times them out.src/node/hooks/express/openapi.ts decides whether to use req.body by checking Object.keys(req.body).length > 0. For an empty but valid JSON body ({} or []), this check fails and the code falls back to formidable.parse(req), which can hang because the JSON stream was already consumed by express.json().
There is already a working pattern for this in src/node/handler/RestAPI.ts: determine JSON vs form parsing based on Content-Type, not on whether req.body happens to be non-empty.
- src/node/hooks/express/openapi.ts[615-630]
- Branch on
req.headers['content-type'](orreq.is('application/json')) similar toRestAPI.ts: - If no content-type or it starts with
application/json, useformData = req.bodyeven if empty. - Otherwise, use formidable.
- This ensures empty JSON bodies do not trigger formidable and prevents the stream-hang timeout.
[correctness] Empty JSON still hangs
Empty JSON still hangs
In `src/node/hooks/express/openapi.ts`, POST requests with an already-parsed JSON body will still fall back to formidable when `req.body` is `{}`, re-triggering the original timeout/hang because formidable tries to parse an already-consumed request stream. This breaks POST endpoints that legitimately send an empty JSON object or otherwise result in an empty parsed body.openapi.ts falls back to formidable whenever Object.keys(req.body).length === 0. If express.json() already consumed the stream and produced {}, formidable will attempt to parse an already-consumed stream, risking the original hang/timeout.
express.json() is registered globally, and RestAPI.ts already uses a safer Content-Type based decision.
- src/node/hooks/express/openapi.ts[615-630]
- Decide whether to use
req.bodyvs formidable based onreq.headers['content-type'](similar tosrc/node/handler/RestAPI.ts). - For
application/json(or missing content-type if you want to preserve existing behavior), always usereq.bodyeven if it is{}. - Only invoke formidable for
multipart/form-data/application/x-www-form-urlencoded(or other non-JSON content types).
[reliability] Empty JSON still times out
Empty JSON still times out
In src/node/hooks/express/openapi.ts, JSON POST requests with an empty parsed body (e.g., '{}' or '[]') still fall back to formidable.parse(req), which can hang because express.json() has already consumed the request stream. This leaves a reliable timeout/DoS edge case where requests remain stuck until the server times them out.src/node/hooks/express/openapi.ts decides whether to use req.body by checking Object.keys(req.body).length > 0. For an empty but valid JSON body ({} or []), this check fails and the code falls back to formidable.parse(req), which can hang because the JSON stream was already consumed by express.json().
There is already a working pattern for this in src/node/handler/RestAPI.ts: determine JSON vs form parsing based on Content-Type, not on whether req.body happens to be non-empty.
- src/node/hooks/express/openapi.ts[615-630]
- Branch on
req.headers['content-type'](orreq.is('application/json')) similar toRestAPI.ts: - If no content-type or it starts with
application/json, useformData = req.bodyeven if empty. - Otherwise, use formidable.
- This ensures empty JSON bodies do not trigger formidable and prevents the stream-hang timeout.
[reliability] Method lowercasing can throw
Method lowercasing can throw
`c.request.method.toLowerCase()` will throw a TypeError if `c.request.method` is missing or not a string, turning requests into 500s. Previously the strict equality check would not crash in that scenario.c.request.method.toLowerCase() can throw if method is undefined/null/non-string.
The handler signature uses c: any, so runtime type safety matters.
- src/node/hooks/express/openapi.ts[615-616]
- Replace with a guarded normalization:
const method = (c.request.method ?? '').toString().toLowerCase();- or
if ((c.request.method ?? '').toLowerCase() === 'post') { ... } - Keep the case-insensitive behavior without risking a throw.
| PR 7454 (2026-04-04) |
[correctness] Overwrites global window._
Overwrites global window._
`src/static/js/vendors/html10n.ts` now unconditionally assigns `window._ = html10n.get`, overwriting any existing global `_` (commonly underscore/lodash). This is a breaking global side effect that can cause runtime failures in plugins/tests/inline scripts that rely on `window._` being underscore rather than a gettext-style function.The PR now always overwrites window._, which can break any code expecting underscore/lodash on window._.
Etherpad has historically shipped underscore and some environments/tests explicitly load it as a global _. At the same time, Etherpad docs recommend window._('pad.chat') for localization in plugins.
- src/static/js/vendors/html10n.ts[995-1001]
- src/node/utils/Minify.ts[39-47]
- doc/localization.md[38-63]
- Back-compat shim:
- If
window._is already set and is nothtml10n.get, store it somewhere stable first (e.g.window.__underscore = window._) before overwriting. - Then set
window._ = html10n.get.
- Safer assignment policy:
- Only overwrite
window._if it appears to be underscore/lodash AND you preserve it to another global. - Or introduce a dedicated alias (e.g.
window.gettext = html10n.get) and update docs + provide deprecation period. Include a short migration note for plugin authors if behavior changes.
[reliability] Language selection still races
Language selection still races
Even with the getParams() de-dupe, the UI language can still end up nondeterministic because `html10n.localize()` is called asynchronously from both `src/static/js/l10n.ts` (on `indexed`) and `src/static/js/pad.ts` (lang callback), and `html10n.localize()` has no cancellation/serialization. Whichever async localization finishes last wins, so slow networks can still cause the wrong final language (cookie/browser vs URL/server).Multiple async calls to html10n.localize() can be in flight simultaneously (from l10n.ts and from the pad lang setting). Because html10n applies translations when each request completes, the final language is whichever request finishes last.
The PR reduces one race (server vs URL within getParams), but the broader concurrency issue remains.
- src/static/js/l10n.ts[4-16]
- src/static/js/pad.ts[141-173]
- src/static/js/vendors/html10n.ts[474-490]
Implement a last-request-wins guard in html10n:
- Add an incrementing request token/sequence (e.g.
this._localizeSeq++). - Capture the seq in
localize()and in thebuild()callback only applythis.translations = ...if the seq matches the latest. Alternative (less invasive): - Avoid the second localize call by incorporating URL
langinto the initiall10n.tslanguage priority list, and havepad.tsonly update the cookie whenlangis provided.
| PR 7451 (2026-04-04) |
[correctness] Hint not announced
Hint not announced
The new sr-only keyboard hint is connected to #editorcontainer, but focus is moved into the nested editor iframe’s body, so screen readers won’t announce the hint when entering the editor. The hint text will instead appear as standalone screen-reader content in the page reading order, not as contextual help for the editor.The keyboard hint is currently attached to #editorcontainer, but the focused element for editing is inside nested iframes. As a result, screen readers will not announce the hint when the user enters the editor.
The editor iframes are created dynamically in Ace2Editor.init() and appended into #editorcontainer, and focus is moved to the inner iframe body.
- src/templates/pad.html[87-88]
- src/static/js/ace.ts[185-200]
Move the accessible name/description from #editorcontainer to the actual focusable element the user tabs into (the outer editor iframe). Concretely:
- Remove
aria-label/aria-describedbyfrom#editorcontainer. - In
Ace2Editor.init(), after creating/appendingouterFrame, set:
-
outerFrame.setAttribute('aria-label', 'Document editor')(or better: reuse existing title semantics) -
outerFrame.setAttribute('aria-describedby', 'editor-keyboard-hint')This keeps the hint element in the same document as the iframe attributes and makes the hint announce when the iframe receives focus.
[correctness] Readonly state not conveyed
Readonly state not conveyed
The editor body is always given role="textbox" and aria-multiline="true", but readonly mode only flips contentEditable without setting aria-readonly. Screen readers can announce an editable textbox even when the pad is read-only, misrepresenting the editor state.role="textbox" is added to the editor body, but readonly pads do not set aria-readonly, causing ARIA semantics to disagree with actual editability.
Readonly mode calls ace_setEditable(!clientVars.readonly), which toggles contentEditable.
- src/static/js/ace.ts[284-290]
- src/static/js/ace2_inner.ts[464-468]
- src/static/js/pad.ts[524-537]
Update setEditable() in ace2_inner.ts to also set aria-readonly on targetBody:
- When
isEditableis false:targetBody.setAttribute('aria-readonly', 'true') - When
isEditableis true: remove it or set to'false'This keeps screen reader announcements aligned with actual editor permissions.
| PR 7450 (2026-04-04) |
[correctness] Unescaped prefix in RegExp
Unescaped prefix in RegExp
src/static/js/l10n.ts interpolates window.clientVars.cookiePrefix directly into a RegExp constructor, so a configured prefix containing regex metacharacters can throw at runtime or match the wrong cookie, breaking localization selection.src/static/js/l10n.ts constructs a RegExp using the configured cookie prefix without escaping. If the prefix contains any regex metacharacters (for example ., +, [, \), new RegExp(...) can throw or match incorrectly, breaking language detection.
The prefix is a user setting (cookie.prefix) and is not constrained to regex-safe characters.
- src/static/js/l10n.ts[6-8]
- Escape the prefix before inserting it into the regex, e.g.:
const esc = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');-
new RegExp(${esc(cp)}language=((\w{2,3})(-\w+)?)) - Keep the unprefixed fallback match as-is for migration.
[reliability] `cookie.prefix` enabled by default
`cookie.prefix` enabled by default
The new cookie-prefixing behavior is enabled by default via `prefix: 'ep_'`, which changes cookie names even if an admin does not opt in. This violates the requirement that new features be behind a feature flag and disabled by default.Cookie name prefixing is enabled by default (prefix: 'ep_'), which changes behavior without an explicit opt-in.
Compliance requires new features to be behind a feature flag and disabled by default.
- src/node/utils/Settings.ts[532-535]
- settings.json.template[385-395]
[reliability] `express_sid` cookie rename breaks
`express_sid` cookie rename breaks
The express-session cookie name is changed to `${settings.cookie.prefix}express_sid` with no migration fallback, which will invalidate existing login sessions on upgrade. This is a backward-compatibility break introduced by a config/default change without a safe migration path.Changing the express-session cookie name to a prefixed value invalidates existing sessions because the old express_sid cookie will no longer be read.
This PR introduces settings.cookie.prefix and applies it to the express-session cookie name. Backward compatibility should be preserved where feasible (or the default must not break existing behavior).
- src/node/hooks/express.ts[203-235]
- src/node/utils/Settings.ts[532-535]
- doc/cookies.md[5-10]
[correctness] Language cookie not read
Language cookie not read
The UI now persists language to `${cookiePrefix}language`, but the l10n bootstrap still only looks for an unprefixed `language` cookie, so language selection will not persist across reloads when `cookie.prefix` is set. Additionally, `padBootstrap.js` loads l10n before `window.clientVars.cookiePrefix` is set, preventing l10n from using the configured prefix at initialization time.Language is now stored in a prefixed cookie (e.g., ep_language), but the l10n bootstrap reads only the unprefixed language cookie, so language preference will not persist across reloads.
-
padBootstrap.jscurrentlyrequire()s the l10n module before settingwindow.clientVars.cookiePrefix, so l10n cannot depend onclientVarsunless the bootstrap order is changed.
- src/templates/padBootstrap.js[4-12]
- src/static/js/l10n.ts[4-11]
- src/static/js/pad.ts[142-149]
- Ensure
window.clientVars.cookiePrefixis set before loading l10n on pad pages (swap the order inpadBootstrap.js). - Update
src/static/js/l10n.tsto read${cookiePrefix}languagewith fallback tolanguage(for migration/back-compat).
[correctness] Token transfer ignores prefix
Token transfer ignores prefix
`welcome.ts` posts `token` and `prefsHttp` from unprefixed cookies, but the token transfer endpoint now sets only prefixed cookies (`${prefix}token`, `${prefix}prefsHttp`), so token transfer breaks when only prefixed cookies exist. This will fail on fresh installs (default `ep_`) or once unprefixed cookies are cleared.The welcome/tokenTransfer client code reads unprefixed cookies (token, prefsHttp) but the server and other client code now use prefixed cookie names, causing token transfer to fail when only prefixed cookies exist.
- Index page bootstrap (
indexBootstrap.js) does not currently exposecookiePrefix, unlike pad/timeslider bootstraps.
- src/static/js/welcome.ts[22-31]
- src/templates/indexBootstrap.js[1-7]
- src/node/hooks/express/tokenTransfer.ts[15-46]
- Expose
cookiePrefixto the welcome/index page scripts (similar topadBootstrap.js/timeSliderBootstrap.js). - Update
welcome.tsto read${cookiePrefix}token/${cookiePrefix}prefsHttpwith fallback to unprefixed names for migration. - (Optional) Consider accepting both prefixed and unprefixed values server-side if the body is missing fields, to ease migration.
[correctness] Timeslider drops sessionID
Timeslider drops sessionID
`timeslider.ts` now uses `cookiePrefix` for the token cookie but still sends `sessionID: Cookies.get('sessionID')` unprefixed in its socket messages, so HTTP API sessions stop working when `cookie.prefix` is non-empty. This can deny access to private/group pads when `settings.requireSession` is enabled because the server never receives the session cookie value.Timeslider socket messages still send sessionID from the unprefixed cookie name, which breaks HTTP API session authorization when cookie.prefix is non-empty.
-
SecurityManager.checkAccess()depends on the session cookie to authorize whensettings.requireSessionis enabled (and for certain group/private pad flows).
- src/static/js/timeslider.ts[49-56]
- src/static/js/timeslider.ts[97-107]
- Compute
cp = window.clientVars?.cookiePrefix || ''in a scope accessible tosendSocketMsg. - Send
sessionID: Cookies.get(${cp}sessionID) || Cookies.get('sessionID')(matching the pad page behavior).
[correctness] Vite prefix hardcoded empty
Vite prefix hardcoded empty
src/templates/padViteBootstrap.js hardcodes cookiePrefix to "" and overwrites window.clientVars, so Vite-based pad loads (ui/pad.html) cannot honor cookie.prefix and will continue to read/write unprefixed token/sessionID cookies during CLIENT_READY.The Vite bootstrap sets window.clientVars.cookiePrefix to "" unconditionally, which prevents the Vite pad flow from using the configured cookie prefix.
-
ui/pad.htmlimportspadViteBootstrap.jsdirectly. -
pad.tsreadswindow.clientVars.cookiePrefixbefore sendingCLIENT_READYto decide which cookie names to use.
- src/templates/padViteBootstrap.js[4-9]
- ui/pad.html[681-685]
- In
padViteBootstrap.js, do not overwrite an existingwindow.clientVars.cookiePrefix. For example: - Initialize with merge/optional assignment:
window.clientVars = window.clientVars || {};window.clientVars.randomVersionString ||= '...';window.clientVars.cookiePrefix ||= '';- Optionally (to make this actually configurable in the Vite UI), set
window.clientVars.cookiePrefixinui/pad.htmlbefore the module import (e.g., from a build-time env substitution or a small inline script), so developers can testcookie.prefixwith the Vite UI.
[reliability] Prefs cookie loses migration
Prefs cookie loses migration
pad_cookie.ts now unconditionally switches to prefixed prefs/prefsHttp cookie names, so existing user prefs cookies will be ignored when enabling `cookie.prefix`. This will reset user preferences (theme/view options/etc.) after prefix is turned on.When cookie.prefix is enabled, existing prefs/prefsHttp cookies are no longer read because pad_cookie now uses only the prefixed cookie name.
Other cookies (token/sessionID) implement a prefixed-or-unprefixed fallback to support migration when enabling a prefix.
- src/static/js/pad_cookie.ts[22-57]
In pad_cookie initialization:
- If
cookiePrefixis non-empty and the prefixed prefs cookie is missing, try reading the legacy unprefixed cookie. - If legacy prefs exist, write them to the prefixed cookie (optionally deleting the old cookie), then proceed using the prefixed name.
[maintainability] `doc/cookies.md` missing cookie prefix
`doc/cookies.md` missing cookie prefix
The cookie documentation still lists unprefixed cookie names (for example, `express_sid`, `token`, `language`, `sessionID`) and does not mention `cookie.prefix` or how it affects cookie names. This is a documentation gap for a configuration-facing behavior change.doc/cookies.md does not mention cookie.prefix and lists cookie names without considering the prefix behavior.
The PR adds a new configuration setting that changes externally visible cookie names; docs should explain the setting, defaults, and how names change.
- doc/cookies.md[1-18]
- settings.json.template[385-395]
- src/node/utils/Settings.ts[532-538]
[maintainability] cookie.prefix not validated
cookie.prefix not validated
`cookie.prefix` is concatenated into cookie names across server and client without validation, so an invalid prefix can generate cookie names that break cookie parsing/setting and lead to hard-to-debug auth/session issues. This is especially risky because the feature is explicitly user-configurable.An invalid cookie.prefix value can silently break cookie handling because it is concatenated directly into cookie names.
-
reloadSettings()already validates certain settings (e.g.,skinName,socketTransportProtocols) but does not validate cookie-related naming.
- src/node/utils/Settings.ts[529-538]
- src/node/utils/Settings.ts[956-1104]
- Add validation for
settings.cookie.prefixduring settings reload (e.g., restrict to a safe subset such as[A-Za-z0-9_]*or RFC cookie-name token characters). - If invalid, log an error/warn and fall back to a safe default (or reject startup with a clear error).
| PR 7449 (2026-04-04) |
[correctness] Batch renumber misses lists
Batch renumber misses lists
doIndentOutdent() and doInsertList() now suppress per-line renumbering and only call renumberList() once from firstLine, so additional ordered-list segments within the selection can keep stale `start` attributes. This can produce incorrect ordered-list numbering in the editor and in HTML exports for multi-list selections.doIndentOutdent() and doInsertList() batch setLineListType() changes and then call renumberList() only once using firstLine. This can miss renumbering when the selection spans multiple separate ordered lists (or when the first selected line is outside the impacted ordered list), leaving stale start attributes.
-
renumberList(lineNum)only renumbers the contiguous list that containslineNumand returnsnullfor non-list lines and for indent lists. - Selection-based operations can include multiple disjoint list segments.
- src/static/js/ace2_inner.ts[2407-2446]
- src/static/js/ace2_inner.ts[3378-3437]
After applying the batched mods, renumber each distinct ordered-list segment that might have been affected (not just the one near firstLine). For example:
- Scan from
firstLinetolastLine(and possibly ±1 line for boundary effects) to find lines whoselistattribute isnumberNand that start a new contiguous list segment (previous line notnumber*at same/any level, or previous is null/indent). - Call
renumberList()once per discovered segment start. - Deduplicate calls (e.g., track segment starts) to keep complexity O(selection).
[correctness] Indent style breaks getHTML test
Indent style breaks getHTML test
ExportHtml now emits ``, but the backend API import/export golden test still expects `` exactly for indented lists. This will cause the `indentedListsAreNotBullets` getHTML test to fail due to strict string equality.The getHTML output for indent lists now includes style="list-style-type: none;", but an existing golden-string API test still expects the old markup. This will break backend CI.
The API import/export tests compare res.body.data.html to wantHTML with strict equality.
- src/tests/backend/specs/api/importexport.ts[66-71]
- src/tests/backend/specs/api/importexport.ts[261-267]
Update the indentedListsAreNotBullets wantHTML string to match the new ExportHtml output, e.g.:
- from:
<ul class="indent">... - to:
<ul class="indent" style="list-style-type: none;">...Also consider grepping for other hard-coded occurrences of<ul class="indent">expectations that are generated viagetHTML()/ExportHtml.
[maintainability] `indent` export test ineffective
`indent` export test ineffective
The new `indent lines export without bullet markers` test does not actually create any `indent` list lines (it only adds attribs to the pool) and it conditionally asserts only if `class="indent"` appears, so it can pass even if the regression returns. This violates the requirement that bug fixes include a regression test that would fail without the fix.The indent lines export without bullet markers test is not a true regression test because it does not reliably generate indent-type lines and it conditionally skips its assertion.
The test currently calls pad.setText(...) and only adds ['list','indent1'] via pool.putAttrib(...), which does not apply the attribute to any line. It then only asserts if class="indent" appears in exported HTML.
- src/tests/backend/specs/export_list.ts[15-42]
| PR 7448 (2026-04-04) |
[maintainability] Cleanup missing regression test
Cleanup missing regression test
This PR adds new session cleanup behavior (`_cleanup()` / `startCleanup()`), but the diff does not include any automated regression test to prove the bug stays fixed. Without a regression test, the cleanup logic can be unintentionally reverted or broken without detection.The PR introduces new session cleanup logic but does not include an automated regression test that would fail without the fix and pass with it.
This change is intended to prevent unbounded growth of sessionstorage:* records (issue #5010). A regression test should validate that expired sessions and “empty cookie-only” non-expiring sessions are removed by the new cleanup mechanism.
- src/node/db/SessionStore.ts[45-102]
- src/tests/backend/specs/SessionStore.ts[1-220]
[performance] Cleanup overlaps and overloads DB
Cleanup overlaps and overloads DB
_cleanup() calls DB.findKeys('sessionstorage:*') to load all session keys into memory and then performs per-key DB.get()/DB.remove() operations; on large datasets this can take longer than CLEANUP_INTERVAL_MS, causing overlapping cleanups because startCleanup() uses setInterval(). This can lead to sustained DB load, duplicated work, and potential memory exhaustion when the key list is very large.The periodic cleanup can overload the DB and the Node process:
- It loads all
sessionstorage:*keys into memory. - It can run overlapping executions because it is scheduled via
setInterval()with no mutual exclusion.
On instances with millions of session rows, a full scan plus per-key get/remove can exceed the 1-hour interval, causing multiple concurrent cleanups and compounding DB load.
- Add a guard to prevent concurrent
_cleanup()runs (e.g.,this._cleanupRunningor storing the in-flight promise). - Prefer
setTimeoutre-scheduling after completion instead ofsetIntervalto avoid overlap by construction. - Reduce peak memory/DB pressure by batching:
- If
findKeyscannot paginate, consider processing and removing keys in chunks with a concurrency limit, and/or limiting the number of keys processed per run to bound runtime. - src/node/db/SessionStore.ts[45-52]
- src/node/db/SessionStore.ts[67-98]
[reliability] Startup cleanup timeout leaks
Startup cleanup timeout leaks
SessionStore.startCleanup() schedules a one-off setTimeout() but does not store/clear it in shutdown(), so it can still fire after closeServer() has shut down the store (and after restartServer() has created a new one). This can trigger extra concurrent cleanups and log noise, and the startup timeout is also not unref()'d so it can delay process exit for up to 5 seconds.startCleanup() schedules a one-shot startup setTimeout() but does not store its timer ID, so shutdown() cannot cancel it. This allows _cleanup() to run after shutdown/restart and creates avoidable concurrent cleanup work and log noise.
-
closeServer()callssessionStore.shutdown()on restart/shutdown. -
shutdown()currently only clears the interval, not the startup timeout.
- Add a
this._cleanupTimeoutfield and assign the result ofsetTimeout(). - In
shutdown(),clearTimeout(this._cleanupTimeout)and set it tonull. - Consider calling
.unref()on the startup timeout handle as well. - src/node/db/SessionStore.ts[45-61]
[maintainability] Misleading stale-age documentation
Misleading stale-age documentation
The comment/docstring claim stale sessions are determined by “haven't been touched in STALE_SESSION_MAX_AGE_MS”, but _cleanup() never uses STALE_SESSION_MAX_AGE_MS or any age/touch time when deciding to remove non-expiring sessions. This mismatch is misleading for maintenance and suggests incorrect behavior to future readers.The code comments/docstring describe age-based stale cleanup using STALE_SESSION_MAX_AGE_MS, but the implementation does not use that constant or any age/touch timestamp.
This is primarily a maintainability/documentation correctness issue that can mislead future changes.
- Either remove
STALE_SESSION_MAX_AGE_MSand update docstrings/comments to describe the actual behavior (cookie-only sessions), OR implement the intended age-based logic if a reliable timestamp can be derived/stored. - src/node/db/SessionStore.ts[12-16]
- src/node/db/SessionStore.ts[63-66]
- src/node/db/SessionStore.ts[86-97]
| PR 7447 (2026-04-04) |
[correctness] Indent precondition unverified
Indent precondition unverified
The new regression test presses Tab/Shift+Tab but never asserts that the line actually became an indented sub-bullet, so the test can pass even if indentation keystrokes are ignored and thus not exercise the reported bug scenario. This reduces the test’s effectiveness at preventing regressions for #5718.The regression test for #5718 does not verify that the Tab keypress actually created an indented sub-bullet, so the test may pass without reproducing the failing precondition.
Other list tests in this suite validate indentation by asserting presence of .list-bullet2 after Tab.
- src/tests/frontend-new/specs/ordered_list.spec.ts[106-113]
- src/tests/frontend-new/specs/unordered_list.spec.ts[77-98]
After the Tab + text entry (and/or after the subsequent Enter), add an assertion such as:
-
await expect(padBody.locator('div').nth(1).locator('.list-bullet2')).toHaveCount(1);(If the actual DOM useslist-indent2for this scenario, use a combined selector like.list-bullet2, .list-indent2.)
[reliability] Hard-coded sleep in test
Hard-coded sleep in test
The new regression test adds `waitForTimeout(500)`, which slows the suite and can still be flaky on slower CI nodes; the subsequent `toHaveAttribute(..., {timeout: 5000})` expectations already provide waiting. This sleep is therefore likely redundant and should be removed or replaced with a condition-based wait.The test uses a fixed waitForTimeout(500) before assertions that already auto-wait, adding unnecessary latency and potential flakiness.
expect(locator).toHaveAttribute(..., {timeout: 5000}) already retries until the attribute matches or times out.
- src/tests/frontend-new/specs/ordered_list.spec.ts[123-134]
Remove the await page.waitForTimeout(500); line and rely on the existing toHaveAttribute assertions.
(If an explicit wait is still needed, replace with a condition-based wait, e.g., wait for the first OL to have start='1' rather than sleeping a fixed duration.)
| PR 7445 (2026-04-04) |
[reliability] Unasserted pad cleanup
Unasserted pad cleanup
The new createDiffHTML API spec deletes the test pad in an `after()` hook without asserting the HTTP status or response body, so a failing `deletePad` call can silently pass and leave test data behind. This reduces test suite reliability and can mask regressions in the deletePad endpoint.The after() cleanup in createDiffHTML.ts does not assert the deletePad response, so cleanup failures can be missed.
Other backend API tests validate deletePad by asserting HTTP 200, JSON content-type, and res.body.code === 0.
- src/tests/backend/specs/api/createDiffHTML.ts[68-71]
Capture the response from the delete request and add expectations similar to other tests:
.expect(200).expect('Content-Type', /json/)assert.equal(res.body.code, 0)
[reliability] Flaky pad ID generation
Flaky pad ID generation
The new test uses `Date.now()` alone for `testPadId`, which can collide in parallel or near-simultaneous test runs and cause nondeterministic failures. Other backend API specs use a random ID helper to avoid this collision risk.const testPadId = createDiffHTML_${Date.now()}; can collide if tests execute in parallel or two processes start within the same millisecond.
Multiple existing backend API specs define and use a small makeid() helper (random alphanumeric) for pad IDs.
- src/tests/backend/specs/api/createDiffHTML.ts[8-8]
Replace the Date.now()-only ID with a random helper (either copy the makeid() pattern used in other specs, or combine timestamp + random suffix, e.g. ${Date.now()}_${Math.random().toString(36).slice(2)}) to reduce collision risk.
| PR 7442 (2026-04-03) |
[correctness] Wrong install-deps working directory
Wrong install-deps working directory
AGENTS.MD tells users to run `sudo npx playwright install-deps` without `cd src`, but Playwright is a devDependency of the `src` workspace (ep_etherpad-lite) rather than the repo root. Running it from the root can resolve a different Playwright version via `npx`, causing mismatched system/browser dependencies relative to the version actually used by `test-ui`/`test-admin`.AGENTS.MD instructs sudo npx playwright install-deps without changing into the src workspace, where Playwright is actually installed and where the Playwright test scripts run from.
This repo is a workspace/monorepo. @playwright/test is a devDependency in src/package.json, not the root. Running npx playwright from the root may fetch/execute a different Playwright version than the one used by pnpm --filter ep_etherpad-lite run test-ui.
- AGENTS.MD[92-99]
[correctness] Wrong plugin tests location
Wrong plugin tests location
AGENTS.MD claims plugin backend tests live under `src/node_modules/...`, but the backend `test` script actually runs plugin specs from the workspace root `node_modules` (referenced as `../node_modules/...` from within `src`). Following the guide will send contributors to a non-existent/wrong directory and make it harder to confirm plugin test discovery.AGENTS.MD documents plugin backend tests as living under src/node_modules/..., but the repo’s backend test script runs plugin specs from the workspace root node_modules (../node_modules/... from src). This mismatch misleads contributors trying to locate plugin test files.
The ep_etherpad-lite package lives in src/, and its test script references plugin tests via a path that resolves to <repo-root>/node_modules/ep_*/static/tests/backend/specs/**.
- AGENTS.MD[81-85]
- src/package.json[134-138]
Update the AGENTS.MD bullet to point to the correct location (for example: node_modules/ep_*/static/tests/backend/specs/ at repo root, or explicitly explain it’s ../node_modules/... relative to src/).
[correctness] Incorrect Playwright browsers
Incorrect Playwright browsers
AGENTS.MD states frontend E2E tests run on chromium, firefox, and webkit by default, but the Playwright config only defines chromium and firefox projects (webkit is commented out). This creates incorrect expectations about browser coverage when running `test-ui`.AGENTS.MD claims Playwright E2E runs on chromium, firefox, and webkit by default, but the Playwright config only includes chromium and firefox.
The projects list in src/playwright.config.ts defines which browsers run by default.
- AGENTS.MD[107-110]
- src/playwright.config.ts[31-44]
Either:
- Update AGENTS.MD to say it runs on chromium and firefox by default (and mention webkit is disabled), or
- Re-enable a webkit project in
src/playwright.config.tsif webkit coverage is intended.
| PR 7438 (2026-04-02) |
[correctness] Identity skip stalls revision
Identity skip stalls revision
broadcast.ts now skips applyChangeset() for identity changesets, but applyChangeset() is also what advances padContents.currentRevision/currentTime and (when following latest) moves the slider; skipping it can leave the timeslider desynced/stuck on an older revision. This can break live-following after an identity revision arrives and can break scrubbing because goToRevision() continues computing paths from a stale padContents.currentRevision.The new identity-changeset guards skip applyChangeset(), but that function also advances padContents.currentRevision/currentTime and (in live-follow mode) moves the slider. For identity revisions, you still need to advance revision/time (and potentially slider) without calling mutateAttributionLines()/mutateTextLines().
-
applyChangeset()is responsible for updatingpadContents.currentRevision,padContents.currentTime, and (whenpreventSliderMovementis false)BroadcastSlider.setSliderPosition(revision). - The PR added
!isIdentity(...)checks at multiple call sites, so identity revisions currently do not advance state.
- src/static/js/broadcast.ts[135-196]
- src/static/js/broadcast.ts[198-207]
- src/static/js/broadcast.ts[265-315]
- Move the identity handling into
applyChangeset():
- At the top of
applyChangeset(), ifisIdentity(changeset)then: - If
!preventSliderMovement, do the slider movement bookkeeping (goToRevisionIfEnabledCount+++BroadcastSlider.setSliderPosition(revision)). - Update
padContents.currentRevision = revisionandpadContents.currentTime += timeDelta. - Call
updateTimer()and update authors UI as needed. - Return early (do not call the mutators).
- Then remove (or relax) the
!isIdentity(...)guards at the call sites.
- Alternatively, add a small helper like
advanceRevision(revision, timeDelta, preventSliderMovement)and call it in theisIdentitybranches at each call site. Either way, ensure live-following continues across identity revisions andgoToRevision()ends withpadContents.currentRevision === path.reveven if the composed changeset is identity.
[maintainability] Unused identity import
Unused identity import
broadcast.ts imports `identity` from Changeset.ts but never uses it, which is likely to be flagged by linting and fail the repo’s eslint-based lint script. This also increases confusion because the fix only uses `isIdentity()`.identity is imported from ./Changeset in broadcast.ts but never used.
The project runs eslint . via the lint script, and unused imports are typically reported.
- src/static/js/broadcast.ts[26-33]
Remove identity from the import list unless you end up using it as part of the identity-changeset handling refactor.
[maintainability] Identity case not exercised
Identity case not exercised
The new regression spec never verifies that an identity changeset scenario is actually reached (nor that playback advanced), so it can pass without executing the `isIdentity()` branch that this PR adds. This reduces the test’s ability to prevent regressions of the identity-changeset crash path.The new Playwright test does not prove that the identity-changeset handling added to timeslider playback is actually exercised. It only checks for absence of error popups after a fixed delay.
The fix in the PR is gated on isIdentity(changeset) (identity is unpack(cs).ops === '' && oldLen === newLen). A regression test should ensure it hits that condition and that playback/scrubbing actually advances.
- src/tests/frontend-new/specs/timeslider_identity_changeset.spec.ts[13-52]
- src/static/js/broadcast.ts[135-203]
- Adjust the revision setup to reliably create a net-zero span (e.g., rev0 empty → rev1 type "X" → rev2 delete-all back to empty), then jump across that span in timeslider (a jump forces composition and is where identity changesets can appear).
- Add an assertion that playback/scrubbing progressed (examples:
#ui-slider-handlestyle/position changes, or#timertext changes between two waits). - Keep the existing “no gritter errors” assertion as a secondary signal.
[maintainability] Scrub coverage incomplete
Scrub coverage incomplete
The “scrub through all revisions” test only navigates to `#0` once and then checks slider visibility, so it does not actually scrub across revisions and won’t catch failures that occur when moving through multiple revisions. This leaves the intended scrubbing regression scenario largely untested.The current scrub test does not scrub across revisions; it only jumps once to #0 and asserts the slider is visible.
The bug being fixed is in the timeslider changeset application path during playback/scrubbing. To validate scrubbing, the test should move across multiple revisions (preferably including a span that can compose to an identity changeset) and assert no errors.
- src/tests/frontend-new/specs/timeslider_identity_changeset.spec.ts[54-92]
- Determine the latest revision number (for example via a page.evaluate reading the slider max if available, or by tracking how many edits you made).
- Loop through multiple revision targets (e.g., 0 → latest in steps, or every revision) using hash navigation or simulated slider dragging.
- After each move, wait for a reliable signal of revision application (e.g.,
#timerchange) and assert no.gritter-item.erroris present.
[maintainability] Misleading identity-skip comment
Misleading identity-skip comment
In goToRevision(), the comment says identity changesets are skipped, but the code still calls applyChangeset() for any truthy changeset string (identity changesets are truthy). This comment/code mismatch is likely to confuse future maintainers and risks an incorrect refactor that reintroduces timeslider desync bugs.A comment in goToRevision() says identity changesets are skipped, but the code still calls applyChangeset() for identity changesets (they are truthy strings). This mismatch can mislead future refactors.
Identity changesets are correctly handled inside applyChangeset() (mutations are skipped, but revision/time state still advances). The call site should reflect that.
- src/static/js/broadcast.ts[288-292]
- Docs
- Translating
- HTTP API
- Plugin framework (API hooks)
- Plugins (available)
- Plugins (list)
- Plugins (wishlist)
- Etherpad URIs / URLs to specific resources IE export
- Etherpad Full data export
- Introduction to the source
- Release Procedure
- Etherpad Developer guidelines
- Project to-do list
- Changeset Library documentation
- Alternative Etherpad-Clients
- Contribution guidelines
- Installing Etherpad
- Deploying Etherpad as a service
- Deploying Etherpad on CloudFoundry
- Deploying Etherpad on Heroku
- Running Etherpad on Phusion Passenger
- Putting Etherpad behind a reverse Proxy (HTTPS/SSL)
- How to setup Etherpad on Ubuntu 12.04 using Ansible
- Migrating from old Etherpad to Etherpad
- Using Etherpad with MySQL
- Customizing the Etherpad web interface
- Enable import/export functionality with AbiWord
- Getting a list of all pads
- Providing encrypted web access to Etherpad using SSL certificates
- Optimizing Etherpad performance including faster page loads
- Getting to know the tools and scripts in the Etherpad /bin/ folder
- Embedding a pad using the jQuery plugin
- Using Embed Parameters
- Integrating Etherpad in a third party app (Drupal, MediaWiki, WordPress, Atlassian, PmWiki)
- HTTP API client libraries