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