Skip to content

Commit 9c8d16d

Browse files
authored
fix: pre-auth path traversal in /static/* handler (GHSA-mc8w-wjhw-45x5) (#8081)
Guards the backslash→slash conversion in Minify.ts to Windows only, closing an unauthenticated arbitrary file read via ..%5C in /static/*. Adds a backend regression test. Also adds the 3.3.3 CHANGELOG entry. Reported by @gcm-explo1t.
1 parent f8686dd commit 9c8d16d

3 files changed

Lines changed: 108 additions & 7 deletions

File tree

CHANGELOG.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,24 @@
1+
# 3.3.3
2+
3+
3.3.3 is a security release. It closes a **critical unauthenticated arbitrary-file-read** in the `/static/*` handler (GHSA-mc8w-wjhw-45x5) and bundles the fixes for a batch of privately reported issues that had already landed on `develop`: an OpenID Connect provider hardcoded cookie key and permissive CORS reflection (GHSA-pp5v-mvwg-76mp), session-fixation on authentication (GHSA-73h9-c5xp-gfg4), a same-socket cross-pad write TOCTOU (GHSA-6mcx-x5h6-rpw2), and a pad-id delimiter injection in `copyPad`/`movePad` (GHSA-wg58-mhwv-35pq). Alongside the security work it migrates the server build to TypeScript 7 (`tsgo`), fixes PageDown/PageUp navigation across consecutive long wrapped lines, and makes the docker `plugin_packages` volume mountpoint writable.
4+
5+
### Security
6+
7+
- **Prevent pre-auth path traversal / arbitrary file read in `/static/*` (GHSA-mc8w-wjhw-45x5, #8081).** On POSIX a backslash is an ordinary filename byte, so `sanitizePathname()` deliberately leaves an `..\..\..` segment untouched — but `Minify.ts` then converted backslashes to forward slashes *unconditionally, after* the sanitiser, turning those bytes back into `../` traversal components with no re-check. Because the route is mounted on `expressPreSession` (before the auth middleware), any unauthenticated client could read any file readable by the Etherpad process — e.g. `GET /static/plugins/ep_etherpad-lite/static/..%5C..%5C..%5Cetc/passwd` — escalating via disclosed `settings.json`/`credentials.json`/`/proc/self/environ` to an admin session and, through the plugin installer, RCE. The backslash conversion is now guarded to Windows only (`path.sep === '\\'`), matching the invariant already enforced in `sanitizePathname.ts`. Adds a backend regression test that fails on the pre-fix code. Reported by @gcm-explo1t.
8+
- **Stop shipping a hardcoded OIDC cookie key and reflecting arbitrary CORS origins (GHSA-pp5v-mvwg-76mp, #8070, #8071, #8072).** The embedded OpenID Connect provider shipped a hardcoded cookie-signing key (allowing forged provider cookies) and `clientBasedCORS` reflected any request `Origin`. The provider now derives its cookie keys from the instance secret, CORS reflection is constrained, the soffice export path strips remote images to match the native path, and public routes that echo `x-proxy-path` set `Vary` to prevent cache poisoning. Reported by meifukun.
9+
- **Regenerate the session id on authentication (GHSA-73h9-c5xp-gfg4, #8074).** Etherpad did not rotate the session identifier when a user authenticated, so a pre-auth session id fixed by an attacker (most impactfully via `ep_openid_connect` SSO) survived login, enabling session-fixation account/admin takeover. The session id is now regenerated on the authentication boundary.
10+
- **Apply queued `USER_CHANGES` to the enqueue-time pad (GHSA-6mcx-x5h6-rpw2, #8075).** A same-socket `CLIENT_READY` pad-swap could redirect an already-queued `USER_CHANGES` onto a different (read-only or unauthorized) pad, a cross-pad write. Queued changes are now bound to the pad they were enqueued against.
11+
- **Reject the ueberdb key delimiter `:` in `copyPad`/`movePad` destination ids (GHSA-wg58-mhwv-35pq, #8073).** A destination id containing `:` could bypass the `force=false` overwrite guard and corrupt another pad's revision records. Such ids are now rejected.
12+
13+
### Notable enhancements
14+
15+
- **Migrate the server build to TypeScript 7 / `tsgo` (#8039).** The server now type-checks and builds under the native-Go TypeScript compiler.
16+
17+
### Notable fixes
18+
19+
- **Editor — PageDown/PageUp now advance across consecutive long wrapped lines (#7555).** Paging no longer stalls when several long soft-wrapped lines follow one another.
20+
- **Docker — make the `plugin_packages` volume mountpoint writable (#8042).** Mounting a plugin-packages volume no longer fails on a read-only mountpoint.
21+
122
# 3.3.2
223

324
3.3.2 is a bug-fix and dependency-hardening follow-up to 3.3.1. It rounds out the pad-deletion UX rework (suppressing the recovery token for durable identities, keeping the token-less Delete button reachable, and closing a read-only deletion hole), restores the saved-revision markers that went missing from in-pad history mode in 3.3.x, and adds env-var overrides so air-gapped installs can switch off Etherpad's outbound calls without editing the image. It also fixes the `migrateDB` / `importSqlFile` / `migrateDirtyDBtoRealDB` CLI scripts against the promise-based ueberdb2 API, rejects unreachable `.`/`..` pad ids, and clears a batch of dependency security advisories (including CVE-2026-54285). On the CI side it unblocks the installer smoke test (which had been hanging the full 6-hour job ceiling since 3.2.0) and pins ueberdb2 past a startup-exit regression in the packaged boot.

src/node/utils/Minify.ts

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -177,13 +177,18 @@ const _minify = async (req:any, res:any) => {
177177
const plugin = plugins.plugins[library];
178178
const pluginPath = plugin.package.realPath;
179179
filename = path.join(pluginPath, libraryPath);
180-
// On Windows, path.relative converts forward slashes to backslashes. Convert them back
181-
// because some of the code below assumes forward slashes. Node.js treats both the backlash
182-
// and the forward slash characters as pathname component separators on Windows so this does
183-
// not change the meaning of the pathname. This conversion does not introduce a directory
184-
// traversal vulnerability because all '..\\' substrings have already been removed by
185-
// sanitizePathname.
186-
filename = filename.replace(/\\/g, '/');
180+
// On Windows, path.join converts forward slashes to backslashes. Convert them back because
181+
// some of the code below assumes forward slashes. Node.js treats both the backslash and the
182+
// forward slash characters as pathname component separators on Windows so this does not
183+
// change the meaning of the pathname on Windows.
184+
//
185+
// THIS CONVERSION MUST ONLY BE DONE ON WINDOWS. On POSIX systems a backslash is an ordinary
186+
// filename byte, not a separator, so sanitizePathname() deliberately leaves '..\\' segments
187+
// untouched (they are harmless there). Replacing '\\' with '/' unconditionally would turn
188+
// those already-sanitized bytes back into '../' path components *after* the traversal check
189+
// has run, reintroducing a directory-traversal / arbitrary-file-read vulnerability
190+
// (GHSA-mc8w-wjhw-45x5).
191+
if (path.sep === '\\') filename = filename.replace(/\\/g, '/');
187192
} else if (LIBRARY_WHITELIST.indexOf(library) !== -1) {
188193
// Go straight into node_modules
189194
// Avoid `require.resolve()`, since 'mustache' and 'mustache/index.js'
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
'use strict';
2+
3+
/**
4+
* Regression coverage for GHSA-mc8w-wjhw-45x5 — a pre-auth path-traversal /
5+
* arbitrary-file-read in the /static/* handler (src/node/utils/Minify.ts).
6+
*
7+
* On POSIX systems a backslash is an ordinary filename byte, so
8+
* sanitizePathname() deliberately leaves a segment such as `..\..\..` untouched
9+
* (it contains no `.`/`..` *path components*). Minify.ts used to convert `\` to
10+
* `/` unconditionally *after* the sanitiser had run, turning those bytes back
11+
* into `../` traversal components with no re-check, e.g.
12+
*
13+
* GET /static/plugins/ep_etherpad-lite/static/..%5C..%5C..%5C..%5Cetc/passwd
14+
*
15+
* would return /etc/passwd to any unauthenticated client. The route is mounted
16+
* on expressPreSession, before the auth middleware, so no credentials were
17+
* required. See the advisory for the full RCE escalation.
18+
*
19+
* The fix guards that conversion to Windows only. These tests assert the
20+
* encoded-backslash traversal no longer escapes the plugin's static root, while
21+
* ordinary static assets are still served.
22+
*/
23+
24+
const common = require('../common');
25+
26+
let agent: any;
27+
28+
// %5C is an encoded backslash — the only separator that survived the sanitiser
29+
// on POSIX. %2F would decode to `/`, be re-split, and get rejected by the
30+
// existing `..` guard, so the exploit relied on %5C specifically.
31+
const BS = '%5C';
32+
33+
describe(__filename, function () {
34+
before(async function () { agent = await common.init(); });
35+
36+
describe('pre-auth arbitrary file read via /static/* (GHSA-mc8w-wjhw-45x5)', function () {
37+
// Over-shoot the traversal depth; surplus `..` is absorbed at `/`, so this
38+
// is root-depth agnostic and would resolve to /etc/passwd if vulnerable.
39+
const climb = `..${BS}`.repeat(10);
40+
41+
it('does not disclose /etc/passwd through the ep_etherpad-lite static route', async function () {
42+
const res = await agent.get(`/static/plugins/ep_etherpad-lite/static/${climb}etc/passwd`);
43+
if (res.status === 200 && /root:.*:0:0:/.test(res.text || '')) {
44+
throw new Error(
45+
'regression: /static/* leaked /etc/passwd (GHSA-mc8w-wjhw-45x5) — ' +
46+
`status ${res.status}`);
47+
}
48+
// The traversal segment is now a literal (non-existent) filename → 404.
49+
if (res.status !== 404) {
50+
throw new Error(`expected 404 for traversal payload, got ${res.status}`);
51+
}
52+
});
53+
54+
it('does not disclose settings via /proc/self/cwd', async function () {
55+
const res =
56+
await agent.get(`/static/plugins/ep_etherpad-lite/static/${climb}proc/self/cwd/settings.json`);
57+
if (res.status === 200 && /(sessionKey|dbSettings|"admin")/.test(res.text || '')) {
58+
throw new Error(
59+
'regression: /static/* leaked settings.json via /proc/self/cwd ' +
60+
'(GHSA-mc8w-wjhw-45x5)');
61+
}
62+
if (res.status !== 404) {
63+
throw new Error(`expected 404 for /proc/self/cwd payload, got ${res.status}`);
64+
}
65+
});
66+
67+
it('still serves a legitimate plugin static asset', async function () {
68+
// Sanity check that the fix did not break normal static file serving.
69+
const res = await agent.get('/static/plugins/ep_etherpad-lite/static/skins/no-skin/pad.js');
70+
if (res.status !== 200) {
71+
throw new Error(`legitimate static asset should be served, got ${res.status}`);
72+
}
73+
});
74+
});
75+
});

0 commit comments

Comments
 (0)