SPDX-License-Identifier: AGPL-3.0-or-later
Etherpad integration is centralized in lib/Service/EtherpadClient.php.
All Etherpad operations are executed through HTTP API calls with the configured API version.
For authenticated server-side Etherpad API calls, parameters (including API key) are sent in
application/x-www-form-urlencoded POST bodies instead of URL query strings.
Important:
- This app requires Etherpad API key mode (
authenticationMethod: "apikey"). - OAuth-only Etherpad configurations are not supported for this integration.
createPaddeletePadgetTextsetTextgetHTMLgetRevisionsCountgetReadOnlyIDcreateGroupcreateGroupPadcreateAuthorIfNotExistsForcreateSessionlistSessionsOfAuthorlistPadsdeleteGroup
A public pad is a pad. A protected pad is a pad inside a group, plus the
sessions that grant access to that group — and deletePad removes only the
first of those three. Every delete used to call it, so a protected pad left
its group and every session ever issued for it behind, with nothing in
Nextcloud pointing at them and nothing to collect them.
ManagedPadLifecycle::discardIfPresent() is the one place that decides:
- pad id not shaped
g.<group>$<name>→deletePad; - otherwise ask Etherpad what the group holds. Exactly this pad and nothing
else, or nothing at all →
deleteGroup, which removes the group, its pad and its sessions in one call. Anything else →deletePad.
The question is asked rather than inferred, because a binding's pad id does
not have to name a group this app created. A legacy Ownpad .pad file names
its own pad id and the migration binds it as given, so a file written by hand
naming another user's group would, on a plain shape check, have made deleting
that file destroy their group, their pad and their sessions. A group that
holds only the pad being deleted, or nothing at all, has nothing else to
lose.
It is done when it removed something, the pad or the empty group a
protected pad left behind, and when Etherpad says there was nothing left:
no such pad, or no such group. A caller that has just heard the pad is
gone says so (knownAbsent); a public pad then costs no call, a protected
one is still asked about its group.
A group Etherpad cannot list is given up by default: the pad goes alone,
the half that was always safe, and the empty group stays. A caller that
keeps its row and tries again says so (retried) and gets the failed read
instead, with nothing removed: the sweep of files gone for good, which
tries again an hour later. One that cannot try again - the clean-up after
a replacement - takes the default.
The shape rule itself lives in Util\PadId and is the same one that
classifies a binding as protected. It used to be stricter here, so a pad
bound as protected by the loose rule was not recognised as a group pad by
the strict one and its group was left behind — the leak this section is
about, reached from the other side.
public- Direct pad ID (
nc-...). - No group session required.
- Direct pad ID (
protected- GroupPad ID (
g.<group>$<name>). - Access only with a valid Etherpad session (
sessionIDcookie).
- GroupPad ID (
Either type can be switched off in the admin settings, which stops new pads of that type from being created. Existing pads keep opening regardless, so the setting never cuts anyone off from content.
Implemented in lib/Service/PadSessionService.php.
Normal protected open flow:
- Extract group ID from pad ID.
- Resolve Etherpad author context for the Nextcloud user.
- Create an Etherpad session for that group via
createSession. - Set the
sessionIDcookie — see below, it may carry several ids. - Open regular pad URL.
A session grants access to exactly one group, and every protected pad is its own group. The cookie is the only place that state lives, so writing just the new id used to revoke whatever the browser held: a second protected pad in a second tab took the first tab's access away, and Etherpad answered 403 for it.
Etherpad reads the value as a comma-separated list and picks the entry matching the group being opened, so the others survive the write:
- the ids the browser sent are the candidates — nothing is added that was not already there, so an open never re-issues access to a pad the user has since lost;
listSessionsOfAuthorsays which of those belong to this Etherpad author, which group each is for, and how long it lasts; that is what keeps one entry per group rather than one per open;- ids the listing does not know are dropped, and the cookie expires with the longest-lived id it keeps, up to 25 of them.
If listSessionsOfAuthor is unavailable, the open still happens but
carries nothing — one fresh id, as before this mechanism existed — and
logs a warning.
An id the current author does not own cannot be attributed. That covers two cases at once, and only one of them is harmless:
- a protected public share is its own Etherpad author (
nc:public-share:<token>), so its session looks foreign to a logged-in user's author — a share and an authenticated protected pad therefore cannot be open at the same time, which was already true before; - the session of whoever used the browser before looks exactly the same, and carrying it would let the next person to log in keep their pad until it expired.
Since nothing here can tell those apart, both are dropped. On a public share the session listing is not asked for at all: the author there comes from the share token, so every anonymous visitor of one link shares it, and Etherpad deletes no sessions — a link opened by hundreds of people would make every open download hundreds of entries.
The cookie is scoped to the domain Nextcloud and Etherpad share, so it reaches every host under that parent — it is not a pad-host-only cookie, which is how the open request can read it in the first place.
- One cookie now carries several sessions. That is the point of it,
but it also means a single exposure — script on any host under the
shared parent domain, since the cookie cannot be
HttpOnlyon Etherpad before 3.0.0 — yields every protected pad the user has open rather than one. Narrowing the domain is not available: the cookie has to reach Etherpad. - A revoked share stays usable until its session expires. Before, an open of any other protected pad happened to overwrite the cookie and cut it off; that only ever helped if the user opened another pad, and did nothing otherwise. Revoking a share does not end the Etherpad sessions already issued for it: only expiry, a logout, the recipient's account being deleted, or removing the group behind the pad clears them, so the window is the session TTL either way — it is just no longer shortened by accident.
For normal authenticated users, the plugin now caches Etherpad author state per Nextcloud user in server-side user config:
- cached keys:
etherpad_author_idetherpad_author_display_name
- cache scope:
- per Nextcloud user
- not shared across users
- not persisted for public-share pseudo users (
public-share:*)
Open-path behavior:
- Try cached
authorIdfor the current Nextcloud user. - Call
createAuthorIfNotExistsForwith the current display name. This runs on every open even when the cached name still matches: it is the only thing that keeps Etherpad's copy of the name in step with Nextcloud's, and a name that drifted on the Etherpad side — a user renaming themselves in the pad, another integrator — is never repaired otherwise. If the answer is a different author id, the cache follows it. - Build the open context with the cached
authorId. - If anything in that fails with an Etherpad API error:
- clear cached author state
- retry full author bootstrap through
createAuthorIfNotExistsFor - then build the open context again
What the cache is for:
- it holds the author id, so the id does not have to be rediscovered from scratch, and it holds the last synced name so a rename is written back only when it actually changed;
- it is not a way to skip the author call — see step 2.
This keeps the author identity stable across opens without weakening access checks or moving trust to the client.
Cookie details:
- Name:
sessionID secure: truesamesite: Laxhttp_only: depends on the Etherpad release, see below- Domain handling:
- if
etherpad_cookie_domainis set, this value is used as-is - if empty, domain is derived from
etherpad_host- two-label host (for example
example.org) ->example.org - multi-label host (for example
pad.example.org) ->.example.org
- two-label host (for example
- derivation is skipped for IP hosts and invalid host values
- recommendation: use explicit
etherpad_cookie_domainin multi-subdomain/proxy setups
- if
An Etherpad session is a bearer token for one group with a validUntil.
Nothing about losing the Nextcloud session reaches it, so the app takes it
away itself:
- Logout revokes every session of that user – including ones opened in another browser, which is deliberate: a cookie copied off the machine cannot be narrowed down by the cookie you can see, and a shared computer is exactly the case where the copy you can see is not the only one.
- Every open mints a fresh session. Etherpad re-checks
validUntilon every socket message and keeps the session id it was handed when the pad connected – read in 2.7.3, 3.0.0 and 3.3.3 – so a session that expires mid-edit rejects the next keystroke, and no later cookie reaches that socket. Revocation fires on an explicit logout, on the account's deletion and on a delete of the pad's file (below), and is capped, so for most sessions the lifetime is what bounds the window. - Expired sessions are left to the background sweep described below. Only
what is expired by both clocks counts as expired: Etherpad judges
validUntilwith its own, so a session ours calls dead may still be honoured there, and skipping it would leave exactly the access a logout removes. What is live is revoked within a small budget – 25 calls or two seconds, with each call given what is left of it – starting with the sessions this browser is carrying, since the listing arrives oldest first and the ceiling would otherwise spend itself before reaching the one in the cookie of the person who just logged out. What is left over, whether skipped or refused, is counted and logged.
No table of our own is involved: sessions belong to an Etherpad author, the
author is cached per uid, and listSessionsOfAuthor answers the rest. That
cached id is therefore not dropped when an open fails – an emptied cache
cannot be told from a user who never opened a protected pad, and a logout
after a brief outage would revoke nothing.
A delete of the file revokes the pad's sessions – every session of
the pad's group, whoever it was issued to, once the file goes to the
trash or past it, for a folder's protected pads too: the trash keeps the
pad, and a session would keep giving it to whoever holds one. Which
groups, and within what budget: docs/architecture.md, "Trash/Restore".
Deleting an account revokes its sessions, as a logout does, as the
delete starts (RevokeSessionsOnAccountDeleteListener): Nextcloud removes
the account's settings, the cached author among them, before it reports
the account gone. A delete the user backend then refuses has lost them as
a logout would, and the next open makes new ones.
Otherwise, only expiry. Losing a share does not revoke anything, and
neither does a permission downgrade, a disabled account, or a deleted
public link. A session issued before any of those stays valid until
validUntil.
Covering them one event at a time means enumerating every way access can
end, and that list has no natural end – a public link in particular opens
under its own Etherpad author whose id is deliberately never cached, so
there is nothing to look the sessions up by. The direction that does close
them is the other one: short sessions that have to be renewed against a
live permission check.
A session can still expire while someone is editing. Etherpad rejects the next message rather than the next reload, and nothing renews a session mid-edit – the pad talks to Etherpad directly once it is open. The window is the configured TTL, counted from when the pad was opened.
Reopening a pad leaves the earlier session behind. Every open mints one and only the cookie forgets the previous, so a pad reopened often carries several live sessions for one group. A logout is one more reader of an index whose length is the subject of the next section.
Revocation cannot outrun an open that is already in flight. A request that has passed its permission check can issue a session after a revoke has listed what to remove. The window is short and the outcome is one more session of the configured lifetime.
Two Nextclouds pointed at one Etherpad share an author. The mapper is
nc:<uid>, which Etherpad stores globally, so one instance's logout can
end the other's sessions. Naming it per instance needs a migration – the
mapper is asked for on every open, so changing its shape re-issues an
author for every existing user and orphans their live sessions – and is not
done here.
A failed revoke is not retried. If the pad server cannot be reached the listing fails, nothing is removed, and the logout carries on regardless – deliberately, since a logout may not fail because Etherpad is down. On a shared machine that leaves live sessions behind and a cookie still naming them; a listener has no response, so the cookie is never cleared either.
Every open of a protected pad mints a session – since 1.0.0 – and Etherpad
never removes one once it expires. Nothing read the pile until 1.1.0-alpha.4,
when keeping several pads open at once began checking which cookie ids are
still valid: listSessionsOfAuthor walks the author's whole index one
awaited lookup at a time, expired entries included. The cost of an open
therefore grows with past opens rather than with live access.
An open leaves the author's id in the job table; a queued job does the rest. The id says which author to look at, not whether there is anything to collect – that answer is the listing, and the listing is the slow call, so it belongs in the job together with the deleting. This also reaches the two cases a request could not: the first open of a browsing session carries no cookie ids and so makes no listing, and a public link never carries any, although every visitor of one adds a session under the same shared author.
The id is also all that is stored. A public link's uid is
public-share:<token>, the credential from the share URL, and job
arguments are persisted and printed by occ.
A run deletes up to 250 sessions within 20 seconds, requeueing itself for
the rest. A refusal is requeued with a growing delay and a limit; a single
session the server will never delete is skipped rather than allowed to
block the ones behind it. A run with nothing to do comes back when the
earliest session still standing falls due, which also keeps the next open
from queueing a second sweep. Nothing is deleted until five minutes after
expiry, because Etherpad judges validUntil against its own clock and a
session dead by ours may still be live there.
This assumes deleting a session removes its id from the author index. Verified on 2.5.3 with PostgreSQL and on 2.x with the built-in store; an integration test pins it by counting raw API keys, since the client filters out exactly the entries a surviving key produces. Where entries do survive, collecting cannot shrink the index and the sweep says so in the log.
Not covered: sessions still being created – that is what keeps an open pad working – and authors nobody opens a pad for, whose leftovers cost storage only. Recording each session's id at issue time would remove the listing; renewing sessions instead of minting them would remove the pile.
Nextcloud and Etherpad have to share a registrable domain for a protected
pad to work at all – a browser rejects a Set-Cookie whose Domain= is not
a suffix of the host that set it. So the pad iframe is a same-site
subresource, Lax covers it, and a foreign page that frames a pad URL gets
no session cookie with it: the pad renders unauthenticated instead of as the
visiting user.
Strict is deliberately not used: it would also withhold the cookie from a
top-level navigation, so a pad link in an email would open unauthenticated.
None exists as an opt-in and is never inferred:
occ config:app:set etherpad_nextcloud etherpad_session_cookie_samesite --value=noneIt is needed by one deployment: a foreign site framing the embed routes.
Those routes are NoAdminRequired, so they need an authenticated Nextcloud
request – and Nextcloud sends its own session cookie as Lax, so a
cross-site frame is not logged in through it. What makes such an embed work
anyway is authentication that does not travel in a cookie: a proxy-injected
REMOTE_USER, Kerberos, or SAML in environment mode. That is not something
this app can detect, so an admin who runs it says so. The connection test
warns while the setting is on, and names what Nextcloud's own cookie does.
An embed origin under the same registrable domain – portal.example.org
framing cloud.example.org – is same-site throughout and needs none of
this. The connection test names any trusted embed origin the session cookie
domain does not reach, because that is the configuration that otherwise
fails in silence: the embedded pad gets no session and nothing says why.
A writable protected public share does mint a session, and its page carries
no frame-ancestors of its own – but any installed app may add one through
AddContentSecurityPolicyEvent, which Nextcloud merges into every response.
Lax is the safer value there for exactly that reason, rather than a
guarantee that such a page can never be framed.
Up to Etherpad 2.7.3 the pad app reads sessionID in the browser, so the
cookie has to stay script-readable — HttpOnly there locks the user out of
every protected pad. From 3.0.0 Etherpad takes the session id out of the
socket.io handshake instead, and the cookie can be withheld from any script
on the page. Measured on 2.7.3, 3.0.0 and 3.3.3.
The boundary is the major version — Etherpad 3 and up — and it lives in
EtherpadReleasePolicy::HTTP_ONLY_SINCE_MAJOR. The two test layers restate
it rather than asking the app: tests/e2e/specs/protected-session-cookie-httponly.spec.ts
and tests/integration/e2e-protected-cookie-contract.sh. Moving it means
moving all three and this table.
The app finds this out from GET /health (releaseId), which needs no api
key. /api cannot answer it: it reports 1.3.1 on both 2.7.3 and 3.3.3.
The answer is cached for an hour, retried at most once a minute after a
failure, dropped after six hours without a successful check, and stored
together with the API host it was read from. Not knowing means a readable cookie.
The connection test in the admin settings shows which release was found and what the cookie will be, and warns when the two have drifted apart — which is what a downgrade looks like from the outside.
App config keys. etherpad_http_only_session_cookie and
etherpad_session_cookie_samesite are the two an admin sets; the rest is
this app's own bookkeeping:
| key | meaning |
|---|---|
etherpad_http_only_session_cookie |
Exactly auto (default), yes or no – anything else is ignored, with one warning per hour in the log and a line in the connection test. The escape hatch when detection is wrong. yes against an Etherpad below 3.0 stops every protected pad from opening. |
etherpad_http_only_override_warned_at |
when the warning above was last written, so it is one line an hour rather than one per pad open |
etherpad_release_failed |
JSON: when a check last failed, and for which host. Its own value, because a failure has nothing to say about the release – folding the two together made every failure overwrite the record. |
etherpad_session_cookie_samesite |
Exactly lax (default) or none – anything else, strict included, is ignored and named by the connection test. Only for a cross-site embed behind cookie-independent authentication, see above. |
etherpad_release_state |
JSON: the detected release, the API host it was read from, when it was last confirmed, and when a check last failed. One value on purpose – a check that finishes after the app has been repointed can only write a record that says which server it is about, and the next reader discards it. |
occ config:app:set etherpad_nextcloud etherpad_http_only_session_cookie --value=noRegression safety check:
tests/integration/e2e-protected-cookie-contract.shvalidates the protected open response cookie contract:- one
sessionIDSet-Cookieheader from app flow - includes
SecureandSameSite=Lax HttpOnlypresent exactly when the pad server's/healthreports a release of 3 or newer; skipped with a note when/healthcannot be reached from where the script runs
- one
A share without write permission gets no way to edit, whether it is a public link or an internal share with another user. Both are decided the same way and reach the same two answers.
- Protected pads are rendered by this app, and no Etherpad session is
created at all. The content is fetched fresh on every open, over the
Etherpad API for own pads and over the public HTML export for pads on
other servers – the stored
.padsnapshot is not a fallback, and a failed fetch shows an error with a retry rather than an older text.- Only a small tag whitelist survives (
p, lists, headings, basic inline formatting, block/code tags, anda); dangerous tags are stripped, on the server and again in the browser. - Links stay clickable, and are the only place an attribute survives:
hrefis kept when it names http, https or mailto – an allowlist, not ajavascript:denylist – andtarget="_blank"withrel="noopener noreferrer"is set by both stages. Anything else keeps its text and loses the link. Colours and author highlighting are still dropped. - The fetch runs behind its own endpoint, which re-checks the share and
the
.padbinding on every call. The binding check is what keeps an edited.padfile from pointing this app's API key at somebody else's pad. A "Refresh" button re-runs that one request – not the whole open – so catching up on changes costs one checked read. The text on screen stays while it runs; a refresh that fails is reported beside the button and leaves the last content in place. - Both fetch paths stop at 5 MiB. A pad past that is reported as too
large to preview (
pad_too_large) and stays editable as usual; the limit exists because the view is read per reader on demand, and the body is held twice, once as text and once as the DOM parsed from it. - Etherpad's own read-only view is deliberately not used here.
SecurityManagerresolves a read-only id back to the real pad before any check, so the view needs the same group session as the editable one – and the editable pad id is written in plain text in the.padfile, which a read-only share still lets the recipient read. Handing over a session would therefore hand over editing, with the id supplied by the file itself. Etherpad has no session that grants reading but not writing.
- Only a small tag whitelist survives (
- Public pads get Etherpad's read-only URL (
getReadOnlyID), which is presentation rather than enforcement: a public pad is editable by anyone who has its id, and its id is in the.padfile. Nothing can be enforced there, so Etherpad's own live view is preferred. When the pad server cannot say what that URL is, the pad falls back to this app's read-only view rather than to the editable address.
The decision is isUpdateable() on the file as this user sees it, which
is the update permission bit – (permissions & UPDATE) – and not a lock
check. The share feeds into it, and so does the mount: a read-only
external storage or a group folder ACL puts the pad into the snapshot view
just as a "can view" share does. Not because edits would be lost –
Etherpad stores the pad itself, and a failed sync leaves a stale copy here
rather than lost text – but because an open without write permission in
Nextcloud may not issue a session that writes on the pad server.
One file can be reachable by several paths with different permissions – shared directly and again inside a shared folder – so the id lookup prefers a path the user may write. Otherwise whichever mount came first would decide, and the open, the metadata and the sync could end up on different ones.
The read-only id itself is random – r. plus sixteen characters, stored as
a pad2readonly / readonly2pad mapping – so it reveals nothing about the
pad it belongs to.
Both open paths map Nextcloud share permissions the same way –
PublicViewerController for links, PadOpenService and
PadMetadataService for authenticated users:
- protected share without update permission -> local
.padtext snapshot, no Etherpad cookie - public pad share without update permission -> Etherpad read-only URL
- share with update permission -> Etherpad editable
- API errors are propagated as
EtherpadClientException. - HTTP >= 400 and invalid JSON are treated as explicit failures.
- Critical lifecycle flows log failures and abort in a controlled way (no silent best effort).
- Protected open keeps author-cache fallback defensive:
- stale cached author IDs are cleared automatically when session creation fails
- author name sync failures do not block pad opening