Skip to content

Commit 86edd67

Browse files
JohnMcLearclaude
andauthored
feat: support X-Forwarded-Prefix and X-Ingress-Path (#7802) (#7806)
* docs: design for URL base-path support (#7802) Spec covers the architecture, header handling rules, components touched, backwards-compatibility story, risks, and test plan for honoring X-Forwarded-Prefix / X-Ingress-Path under trustProxy. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs: amend #7802 spec with discovery of pre-existing proxy-path helpers After exploring the codebase, much of the proposed architecture is already in place (sanitizeProxyPath, padBootstrap.js basePath derivation, admin SPA rewrite). Spec now reflects the actual delta: header source expansion, /manifest.json prefix-awareness, socialMeta proxyPath honoring, and template URL touch-ups for index/timeslider/pad/export. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(plan): implementation plan for URL base-path support (#7802) Adds the bite-sized TDD task list to ship X-Forwarded-Prefix / X-Ingress-Path support: extends sanitizeProxyPath, makes /manifest.json and socialMeta prefix-aware, touches up the remaining leading-slash URLs in index/pad/timeslider/export templates, fixes a pre-existing manifest .. count bug. Drops the originally-proposed <base href> belt-and-braces after discovering it'd break the existing relative URLs in pad.html/timeslider.html and wouldn't help plugin DOM injection anyway (path-absolute URLs ignore <base>'s path component). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(proxy): accept X-Forwarded-Prefix and X-Ingress-Path under trustProxy (#7802) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(pwa): make /manifest.json honor sanitised proxy-path (#7802) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(social-meta): honor proxyPath in from-request og:url and og:image (#7802) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(templates): index.html manifest + jslicense links honor proxyPath (#7802) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(templates): pad.html reconnect/jslicense honor proxyPath; fix manifest .. count (#7802) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(templates): timeslider.html reconnect/jslicense honor proxyPath; fix manifest .. count (#7802) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(templates): export_html.html manifest honors proxyPath when available (#7802) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test: end-to-end coverage for X-Forwarded-Prefix / X-Ingress-Path (#7802) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(settings): trustProxy also enables X-Forwarded-Prefix / X-Ingress-Path (#7802) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 4d998d6 commit 86edd67

17 files changed

Lines changed: 2055 additions & 61 deletions

docs/superpowers/plans/2026-05-18-issue-7802-url-base-path.md

Lines changed: 1264 additions & 0 deletions
Large diffs are not rendered by default.

docs/superpowers/specs/2026-05-18-issue-7802-url-base-path-support-design.md

Lines changed: 275 additions & 0 deletions
Large diffs are not rendered by default.

settings.json.template

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -535,6 +535,15 @@
535535
*
536536
* The other effect will be that the logs will contain the real client's IP,
537537
* instead of the reverse proxy's IP.
538+
*
539+
* Setting this to `true` also makes Etherpad honor two standard URL-path-
540+
* prefix headers from upstream proxies:
541+
* - `X-Forwarded-Prefix` (HAProxy / Traefik convention)
542+
* - `X-Ingress-Path` (Home Assistant supervisor ingress)
543+
*
544+
* Both are sanitised before use. Etherpad's own `x-proxy-path` header is
545+
* honored regardless of this setting; the operator is presumed to have
546+
* configured their proxy intentionally when sending the custom header.
538547
*/
539548
"trustProxy": false,
540549

src/node/hooks/express/pwa.ts

Lines changed: 17 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,38 @@
11
import {ArgsExpressType} from "../../types/ArgsExpressType";
22
import settings from '../../utils/Settings';
3+
import {sanitizeProxyPath} from '../../utils/sanitizeProxyPath';
34

4-
const pwa = {
5+
const buildManifest = (proxyPath: string) => ({
56
name: settings.title || "Etherpad",
67
short_name: settings.title,
78
description: "A collaborative online editor",
89
icons: [
910
{
10-
"src": "/static/skins/colibris/images/fond.jpg",
11+
"src": `${proxyPath}/static/skins/colibris/images/fond.jpg`,
1112
"sizes": "512x512",
12-
"type": "image/png"
13+
"type": "image/png",
1314
},
1415
{
15-
"src": "/favicon.ico",
16+
"src": `${proxyPath}/favicon.ico`,
1617
"sizes": "64x64 32x32 24x24 16x16",
17-
type: "image/png"
18-
}
18+
type: "image/png",
19+
},
1920
],
20-
start_url: "/",
21+
start_url: `${proxyPath}/`,
2122
display: "fullscreen",
2223
theme_color: "#0f775b",
23-
background_color: "#0f775b"
24-
}
24+
background_color: "#0f775b",
25+
});
2526

2627
exports.expressCreateServer = (hookName:string, args:ArgsExpressType, cb:Function) => {
2728
args.app.get('/manifest.json', (req:any, res:any) => {
28-
res.json(pwa);
29+
const proxyPath = sanitizeProxyPath(req);
30+
if (proxyPath) {
31+
res.setHeader('Vary', 'x-proxy-path, x-forwarded-prefix, x-ingress-path');
32+
res.setHeader('Cache-Control', 'private, no-store');
33+
}
34+
res.json(buildManifest(proxyPath));
2935
});
3036

3137
return cb();
32-
}
38+
};

src/node/hooks/express/specialpages.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -175,8 +175,9 @@ const handleLiveReload = async (args: ArgsExpressType, padString: string, timeSl
175175
const proxyPath = sanitizeProxyPath(req);
176176
const socialMetaHtml = renderSocialMeta({
177177
req, settings, availableLangs: i18n.availableLangs, locales: i18n.locales, kind: 'home',
178+
proxyPath,
178179
});
179-
res.send(eejs.require('ep_etherpad-lite/templates/index.html', {req, entrypoint: proxyPath + '/watch/index?hash=' + hash, settings, socialMetaHtml}));
180+
res.send(eejs.require('ep_etherpad-lite/templates/index.html', {req, entrypoint: proxyPath + '/watch/index?hash=' + hash, settings, socialMetaHtml, proxyPath}));
180181
})
181182
})
182183

@@ -203,6 +204,7 @@ const handleLiveReload = async (args: ArgsExpressType, padString: string, timeSl
203204
const proxyPath = sanitizeProxyPath(req);
204205
const socialMetaHtml = renderSocialMeta({
205206
req, settings, availableLangs: i18n.availableLangs, locales: i18n.locales, kind: 'pad', padName: req.params.pad,
207+
proxyPath,
206208
});
207209
const content = eejs.require('ep_etherpad-lite/templates/pad.html', {
208210
req,
@@ -211,6 +213,7 @@ const handleLiveReload = async (args: ArgsExpressType, padString: string, timeSl
211213
entrypoint: proxyPath + '/watch/pad?hash=' + hash,
212214
settings: settings.getPublicSettings(),
213215
socialMetaHtml,
216+
proxyPath,
214217
})
215218
res.send(content);
216219
})
@@ -245,6 +248,7 @@ const handleLiveReload = async (args: ArgsExpressType, padString: string, timeSl
245248
const proxyPath = sanitizeProxyPath(req);
246249
const socialMetaHtml = renderSocialMeta({
247250
req, settings, availableLangs: i18n.availableLangs, locales: i18n.locales, kind: 'timeslider', padName: req.params.pad,
251+
proxyPath,
248252
});
249253
const content = eejs.require('ep_etherpad-lite/templates/timeslider.html', {
250254
req,
@@ -254,6 +258,7 @@ const handleLiveReload = async (args: ArgsExpressType, padString: string, timeSl
254258
entrypoint: proxyPath + '/watch/timeslider?hash=' + hash,
255259
settings: settings.getPublicSettings(),
256260
socialMetaHtml,
261+
proxyPath,
257262
})
258263
res.send(content);
259264
})
@@ -363,10 +368,12 @@ exports.expressCreateServer = async (_hookName: string, args: ArgsExpressType, c
363368

364369
// serve index.html under /
365370
args.app.get('/', (req: any, res: any) => {
371+
const proxyPath = sanitizeProxyPath(req);
366372
const socialMetaHtml = renderSocialMeta({
367373
req, settings, availableLangs: i18n.availableLangs, locales: i18n.locales, kind: 'home',
374+
proxyPath,
368375
});
369-
res.send(eejs.require('ep_etherpad-lite/templates/index.html', {req, settings, entrypoint: "./"+fileNameIndex, socialMetaHtml}));
376+
res.send(eejs.require('ep_etherpad-lite/templates/index.html', {req, settings, entrypoint: "./"+fileNameIndex, socialMetaHtml, proxyPath}));
370377
});
371378

372379

@@ -381,8 +388,10 @@ exports.expressCreateServer = async (_hookName: string, args: ArgsExpressType, c
381388
isReadOnly
382389
});
383390

391+
const proxyPath = sanitizeProxyPath(req);
384392
const socialMetaHtml = renderSocialMeta({
385393
req, settings, availableLangs: i18n.availableLangs, locales: i18n.locales, kind: 'pad', padName: req.params.pad,
394+
proxyPath,
386395
});
387396
const content = eejs.require('ep_etherpad-lite/templates/pad.html', {
388397
req,
@@ -391,6 +400,7 @@ exports.expressCreateServer = async (_hookName: string, args: ArgsExpressType, c
391400
entrypoint: "../"+fileNamePad,
392401
settings: settings.getPublicSettings(),
393402
socialMetaHtml,
403+
proxyPath,
394404
})
395405
res.send(content);
396406
});
@@ -414,8 +424,10 @@ exports.expressCreateServer = async (_hookName: string, args: ArgsExpressType, c
414424
toolbar,
415425
});
416426

427+
const proxyPath = sanitizeProxyPath(req);
417428
const socialMetaHtml = renderSocialMeta({
418429
req, settings, availableLangs: i18n.availableLangs, locales: i18n.locales, kind: 'timeslider', padName: req.params.pad,
430+
proxyPath,
419431
});
420432
res.send(eejs.require('ep_etherpad-lite/templates/timeslider.html', {
421433
req,
@@ -424,6 +436,7 @@ exports.expressCreateServer = async (_hookName: string, args: ArgsExpressType, c
424436
entrypoint: "../../"+fileNameTimeSlider,
425437
settings: settings.getPublicSettings(),
426438
socialMetaHtml,
439+
proxyPath,
427440
}));
428441
});
429442
} else {

src/node/utils/ExportHtml.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -532,6 +532,7 @@ exports.getPadHTMLDocument = async (padId: string, revNum: string, readOnlyId: n
532532
body: html,
533533
padId: Security.escapeHTML(readOnlyId || padId),
534534
extraCSS: stylesForExportCSS,
535+
proxyPath: '',
535536
});
536537
};
537538

src/node/utils/Settings.ts

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -721,9 +721,19 @@ const settings: SettingsType = {
721721
* Deprecated cookie signing key.
722722
*/
723723
sessionKey: null,
724-
/*
725-
* Trust Proxy, whether or not trust the x-forwarded-for header.
726-
*/
724+
/**
725+
* Trust Proxy, whether or not trust the x-forwarded-for header.
726+
*
727+
* Setting this to `true` also makes Etherpad honor two standard URL-path-
728+
* prefix headers from upstream proxies:
729+
* - `X-Forwarded-Prefix` (HAProxy / Traefik convention)
730+
* - `X-Ingress-Path` (Home Assistant supervisor ingress)
731+
*
732+
* Both are sanitised before use (see src/node/utils/sanitizeProxyPath.ts).
733+
* Etherpad's own `x-proxy-path` header is honored regardless of this
734+
* setting; the operator is presumed to have configured their proxy
735+
* intentionally when sending the custom header.
736+
*/
727737
trustProxy: false,
728738
/*
729739
* Settings controlling the session cookie issued by Etherpad.
Lines changed: 49 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,48 +1,65 @@
1+
import settings from './Settings';
2+
13
/**
2-
* Sanitize the `x-proxy-path` request header.
4+
* Sanitize the URL-path prefix Etherpad is being served under.
35
*
4-
* Etherpad lets operators run behind a reverse proxy that prefixes every
5-
* route under a subpath (e.g. `/pad/etherpad/...`). The proxy is expected
6-
* to set `x-proxy-path` so that server-rendered links and redirects know
7-
* about the prefix. The header value is then woven into HTML, JS, CSS,
8-
* and HTTP Location headers — so it must be treated as untrusted input
9-
* even if the deployment intends to set it from a trusted proxy.
6+
* Headers checked in order; first non-empty (after sanitization) wins:
7+
* 1. `x-proxy-path` — Etherpad's own convention; always honored because
8+
* the operator must explicitly configure their proxy to send it.
9+
* 2. `x-forwarded-prefix` — HAProxy / Traefik standard.
10+
* 3. `x-ingress-path` — Home Assistant supervisor ingress.
1011
*
11-
* Semantics:
12-
* - Returns an empty string when the header is absent or unparseable.
12+
* The two standard headers (everything other than x-proxy-path) are honored
13+
* ONLY when `settings.trustProxy === true`, because they can otherwise be
14+
* forged by any internet client when Etherpad runs on a public IP.
15+
*
16+
* The header value is woven into HTML, JS, CSS and HTTP Location headers,
17+
* so the same value is also treated as untrusted input even when read from
18+
* a trusted header. Sanitization rules:
1319
* - Strips every character outside `[a-zA-Z0-9\-_\/\.]`.
14-
* - Collapses a leading `//+` to a single `/` so the value can never
15-
* be interpreted as a protocol-relative URL.
16-
* - Prepends `/` if the (non-empty) result doesn't already start
17-
* with one, so callers can always concatenate the value as an
18-
* absolute path prefix.
20+
* - Collapses a leading `//+` to a single `/` so the value can never be
21+
* interpreted as a protocol-relative URL.
22+
* - Prepends `/` if the (non-empty) result doesn't already start with one,
23+
* so callers can always concatenate the value as an absolute path prefix.
1924
* - Rejects values containing `..` segments.
2025
*
2126
* The output is always either the empty string or a string that starts
2227
* with exactly one `/` and contains only `[A-Za-z0-9\-_./]`.
2328
*/
24-
export const sanitizeProxyPath = (req: {header: (n: string) => string|undefined} | string | undefined): string => {
25-
const raw = typeof req === 'string'
26-
? req
27-
: req && typeof req.header === 'function'
28-
? (req.header('x-proxy-path') || '')
29-
: '';
29+
30+
const HEADER_NAMES = [
31+
// [headerName, requiresTrustProxy]
32+
['x-proxy-path', false] as const,
33+
['x-forwarded-prefix', true] as const,
34+
['x-ingress-path', true] as const,
35+
];
36+
37+
const cleanOne = (raw: string): string => {
3038
let cleaned = raw.replace(/[^a-zA-Z0-9\-_\/\.]/g, '');
3139
if (!cleaned) return '';
32-
// Collapse leading "//+" to a single "/" so the value can never be
33-
// interpreted as a protocol-relative URL when concatenated into an
34-
// href / Location / iframe src.
3540
cleaned = cleaned.replace(/^\/{2,}/, '/');
36-
// Ensure the value starts with exactly one "/". Several callers
37-
// concatenate this as a URL-path prefix (e.g. `${proxyPath}/p/...`
38-
// for redirects, `${proxyPath}/watch/...` for entrypoint URLs) and
39-
// assume the value is either empty or absolute. A header value like
40-
// `pad/etherpad` would otherwise become a relative redirect /
41-
// entrypoint and break the page.
4241
if (cleaned[0] !== '/') cleaned = '/' + cleaned;
43-
// Refuse "/.." / "../" segments — path-traversal shapes that some
44-
// downstream URL joiners would still honour even after the character
45-
// filter above.
4642
if (/(?:^|\/)\.\.(?:\/|$)/.test(cleaned)) return '';
4743
return cleaned;
4844
};
45+
46+
type ReqLike = {header: (n: string) => string|undefined};
47+
48+
export const sanitizeProxyPath = (
49+
req: ReqLike | string | undefined,
50+
opts: {trustProxy?: boolean} = {},
51+
): string => {
52+
// String form preserves the original behaviour for callers that pre-extracted
53+
// the value themselves (e.g. tests). It's treated as a raw value with no
54+
// header-gating: the caller has already decided to use it.
55+
if (typeof req === 'string') return cleanOne(req);
56+
if (!req || typeof req.header !== 'function') return '';
57+
const trustProxy = opts.trustProxy ?? !!settings.trustProxy;
58+
for (const [name, requiresTrust] of HEADER_NAMES) {
59+
if (requiresTrust && !trustProxy) continue;
60+
const raw = req.header(name) || '';
61+
const cleaned = cleanOne(raw);
62+
if (cleaned) return cleaned;
63+
}
64+
return '';
65+
};

src/node/utils/socialMeta.ts

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -139,21 +139,27 @@ const sanitizePublicURL = (raw: string | null | undefined): string | null => {
139139
// Builds an absolute URL. Prefers settings.publicURL when configured (operator-
140140
// trusted); otherwise falls back to the request's protocol+Host with strict
141141
// host validation so a crafted Host header can't appear in og:url / og:image.
142+
// proxyPath is prepended to pathname in the fallback path — it recovers the
143+
// public URL prefix that the reverse proxy stripped before forwarding the
144+
// request. Ignored when publicURL is set (publicURL encodes the canonical
145+
// origin and any path component the operator wants).
142146
const buildAbsoluteUrl = (
143147
req: Request, pathname: string, publicURL: string | null | undefined,
148+
proxyPath: string,
144149
): string => {
145150
const trusted = sanitizePublicURL(publicURL);
146151
if (trusted) return `${trusted}${pathname}`;
147152
const proto = req.protocol === 'https' ? 'https' : 'http';
148153
const host = sanitizeHost(req.get && req.get('host')) || 'localhost';
149-
return `${proto}://${host}${pathname}`;
154+
return `${proto}://${host}${proxyPath}${pathname}`;
150155
};
151156

152157
const resolveImageUrl = (
153158
req: Request, faviconSetting: string | null | undefined, publicURL: string | null | undefined,
159+
proxyPath: string,
154160
): string => {
155161
if (faviconSetting && /^https?:\/\//i.test(faviconSetting)) return faviconSetting;
156-
return buildAbsoluteUrl(req, '/favicon.ico', publicURL);
162+
return buildAbsoluteUrl(req, '/favicon.ico', publicURL, proxyPath);
157163
};
158164

159165
export type RenderOpts = {
@@ -163,6 +169,11 @@ export type RenderOpts = {
163169
locales: {[lang: string]: {[key: string]: string}},
164170
kind: 'pad' | 'timeslider' | 'home',
165171
padName?: string,
172+
// URL-path prefix Etherpad is being served under (`''` when running at root).
173+
// When set, used as a path prefix for from-request fallback URLs. Ignored
174+
// when settings.publicURL is configured (publicURL encodes the canonical
175+
// origin and any path component the operator wants).
176+
proxyPath?: string,
166177
};
167178

168179
// Operator override wins when set. Settings.ts coerces env-var strings to
@@ -189,7 +200,8 @@ export const renderSocialMeta = (o: RenderOpts): string => {
189200
const description = resolveDescriptionWithOverride(
190201
o.settings.socialMeta && o.settings.socialMeta.description,
191202
o.locales, renderLang);
192-
const imageUrl = resolveImageUrl(o.req, o.settings.favicon, o.settings.publicURL);
203+
const proxyPath = o.proxyPath || '';
204+
const imageUrl = resolveImageUrl(o.req, o.settings.favicon, o.settings.publicURL, proxyPath);
193205
const imageAlt = `${siteName} logo`;
194206

195207
let title = siteName;
@@ -203,7 +215,7 @@ export const renderSocialMeta = (o: RenderOpts): string => {
203215
if (qIdx >= 0) pathname = pathname.slice(0, qIdx);
204216

205217
return buildSocialMetaHtml({
206-
url: buildAbsoluteUrl(o.req, pathname, o.settings.publicURL),
218+
url: buildAbsoluteUrl(o.req, pathname, o.settings.publicURL, proxyPath),
207219
siteName,
208220
title,
209221
description,

src/templates/export_html.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
<html lang="en">
33
<head>
44
<title><%- padId %></title>
5-
<link rel="manifest" href="/manifest.json" />
5+
<link rel="manifest" href="<%= typeof proxyPath !== 'undefined' ? proxyPath : '' %>/manifest.json" />
66
<meta name="generator" content="Etherpad"/>
77
<meta name="author" content="Etherpad"/>
88
<meta name="changedby" content="Etherpad"/>

0 commit comments

Comments
 (0)