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>
This commit is contained in:
John McLear 2026-05-18 13:27:59 +01:00 committed by GitHub
parent 4d998d6ef2
commit 86edd67f58
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 2055 additions and 61 deletions

View file

@ -1,32 +1,38 @@
import {ArgsExpressType} from "../../types/ArgsExpressType";
import settings from '../../utils/Settings';
import {sanitizeProxyPath} from '../../utils/sanitizeProxyPath';
const pwa = {
const buildManifest = (proxyPath: string) => ({
name: settings.title || "Etherpad",
short_name: settings.title,
description: "A collaborative online editor",
icons: [
{
"src": "/static/skins/colibris/images/fond.jpg",
"src": `${proxyPath}/static/skins/colibris/images/fond.jpg`,
"sizes": "512x512",
"type": "image/png"
"type": "image/png",
},
{
"src": "/favicon.ico",
"src": `${proxyPath}/favicon.ico`,
"sizes": "64x64 32x32 24x24 16x16",
type: "image/png"
}
type: "image/png",
},
],
start_url: "/",
start_url: `${proxyPath}/`,
display: "fullscreen",
theme_color: "#0f775b",
background_color: "#0f775b"
}
background_color: "#0f775b",
});
exports.expressCreateServer = (hookName:string, args:ArgsExpressType, cb:Function) => {
args.app.get('/manifest.json', (req:any, res:any) => {
res.json(pwa);
const proxyPath = sanitizeProxyPath(req);
if (proxyPath) {
res.setHeader('Vary', 'x-proxy-path, x-forwarded-prefix, x-ingress-path');
res.setHeader('Cache-Control', 'private, no-store');
}
res.json(buildManifest(proxyPath));
});
return cb();
}
};

View file

@ -175,8 +175,9 @@ const handleLiveReload = async (args: ArgsExpressType, padString: string, timeSl
const proxyPath = sanitizeProxyPath(req);
const socialMetaHtml = renderSocialMeta({
req, settings, availableLangs: i18n.availableLangs, locales: i18n.locales, kind: 'home',
proxyPath,
});
res.send(eejs.require('ep_etherpad-lite/templates/index.html', {req, entrypoint: proxyPath + '/watch/index?hash=' + hash, settings, socialMetaHtml}));
res.send(eejs.require('ep_etherpad-lite/templates/index.html', {req, entrypoint: proxyPath + '/watch/index?hash=' + hash, settings, socialMetaHtml, proxyPath}));
})
})
@ -203,6 +204,7 @@ const handleLiveReload = async (args: ArgsExpressType, padString: string, timeSl
const proxyPath = sanitizeProxyPath(req);
const socialMetaHtml = renderSocialMeta({
req, settings, availableLangs: i18n.availableLangs, locales: i18n.locales, kind: 'pad', padName: req.params.pad,
proxyPath,
});
const content = eejs.require('ep_etherpad-lite/templates/pad.html', {
req,
@ -211,6 +213,7 @@ const handleLiveReload = async (args: ArgsExpressType, padString: string, timeSl
entrypoint: proxyPath + '/watch/pad?hash=' + hash,
settings: settings.getPublicSettings(),
socialMetaHtml,
proxyPath,
})
res.send(content);
})
@ -245,6 +248,7 @@ const handleLiveReload = async (args: ArgsExpressType, padString: string, timeSl
const proxyPath = sanitizeProxyPath(req);
const socialMetaHtml = renderSocialMeta({
req, settings, availableLangs: i18n.availableLangs, locales: i18n.locales, kind: 'timeslider', padName: req.params.pad,
proxyPath,
});
const content = eejs.require('ep_etherpad-lite/templates/timeslider.html', {
req,
@ -254,6 +258,7 @@ const handleLiveReload = async (args: ArgsExpressType, padString: string, timeSl
entrypoint: proxyPath + '/watch/timeslider?hash=' + hash,
settings: settings.getPublicSettings(),
socialMetaHtml,
proxyPath,
})
res.send(content);
})
@ -363,10 +368,12 @@ exports.expressCreateServer = async (_hookName: string, args: ArgsExpressType, c
// serve index.html under /
args.app.get('/', (req: any, res: any) => {
const proxyPath = sanitizeProxyPath(req);
const socialMetaHtml = renderSocialMeta({
req, settings, availableLangs: i18n.availableLangs, locales: i18n.locales, kind: 'home',
proxyPath,
});
res.send(eejs.require('ep_etherpad-lite/templates/index.html', {req, settings, entrypoint: "./"+fileNameIndex, socialMetaHtml}));
res.send(eejs.require('ep_etherpad-lite/templates/index.html', {req, settings, entrypoint: "./"+fileNameIndex, socialMetaHtml, proxyPath}));
});
@ -381,8 +388,10 @@ exports.expressCreateServer = async (_hookName: string, args: ArgsExpressType, c
isReadOnly
});
const proxyPath = sanitizeProxyPath(req);
const socialMetaHtml = renderSocialMeta({
req, settings, availableLangs: i18n.availableLangs, locales: i18n.locales, kind: 'pad', padName: req.params.pad,
proxyPath,
});
const content = eejs.require('ep_etherpad-lite/templates/pad.html', {
req,
@ -391,6 +400,7 @@ exports.expressCreateServer = async (_hookName: string, args: ArgsExpressType, c
entrypoint: "../"+fileNamePad,
settings: settings.getPublicSettings(),
socialMetaHtml,
proxyPath,
})
res.send(content);
});
@ -414,8 +424,10 @@ exports.expressCreateServer = async (_hookName: string, args: ArgsExpressType, c
toolbar,
});
const proxyPath = sanitizeProxyPath(req);
const socialMetaHtml = renderSocialMeta({
req, settings, availableLangs: i18n.availableLangs, locales: i18n.locales, kind: 'timeslider', padName: req.params.pad,
proxyPath,
});
res.send(eejs.require('ep_etherpad-lite/templates/timeslider.html', {
req,
@ -424,6 +436,7 @@ exports.expressCreateServer = async (_hookName: string, args: ArgsExpressType, c
entrypoint: "../../"+fileNameTimeSlider,
settings: settings.getPublicSettings(),
socialMetaHtml,
proxyPath,
}));
});
} else {

View file

@ -532,6 +532,7 @@ exports.getPadHTMLDocument = async (padId: string, revNum: string, readOnlyId: n
body: html,
padId: Security.escapeHTML(readOnlyId || padId),
extraCSS: stylesForExportCSS,
proxyPath: '',
});
};

View file

@ -721,9 +721,19 @@ const settings: SettingsType = {
* Deprecated cookie signing key.
*/
sessionKey: null,
/*
* Trust Proxy, whether or not trust the x-forwarded-for header.
*/
/**
* Trust Proxy, whether or not trust the x-forwarded-for header.
*
* Setting this to `true` also makes Etherpad honor two standard URL-path-
* prefix headers from upstream proxies:
* - `X-Forwarded-Prefix` (HAProxy / Traefik convention)
* - `X-Ingress-Path` (Home Assistant supervisor ingress)
*
* Both are sanitised before use (see src/node/utils/sanitizeProxyPath.ts).
* Etherpad's own `x-proxy-path` header is honored regardless of this
* setting; the operator is presumed to have configured their proxy
* intentionally when sending the custom header.
*/
trustProxy: false,
/*
* Settings controlling the session cookie issued by Etherpad.

View file

@ -1,48 +1,65 @@
import settings from './Settings';
/**
* Sanitize the `x-proxy-path` request header.
* Sanitize the URL-path prefix Etherpad is being served under.
*
* Etherpad lets operators run behind a reverse proxy that prefixes every
* route under a subpath (e.g. `/pad/etherpad/...`). The proxy is expected
* to set `x-proxy-path` so that server-rendered links and redirects know
* about the prefix. The header value is then woven into HTML, JS, CSS,
* and HTTP Location headers so it must be treated as untrusted input
* even if the deployment intends to set it from a trusted proxy.
* Headers checked in order; first non-empty (after sanitization) wins:
* 1. `x-proxy-path` Etherpad's own convention; always honored because
* the operator must explicitly configure their proxy to send it.
* 2. `x-forwarded-prefix` HAProxy / Traefik standard.
* 3. `x-ingress-path` Home Assistant supervisor ingress.
*
* Semantics:
* - Returns an empty string when the header is absent or unparseable.
* The two standard headers (everything other than x-proxy-path) are honored
* ONLY when `settings.trustProxy === true`, because they can otherwise be
* forged by any internet client when Etherpad runs on a public IP.
*
* The header value is woven into HTML, JS, CSS and HTTP Location headers,
* so the same value is also treated as untrusted input even when read from
* a trusted header. Sanitization rules:
* - Strips every character outside `[a-zA-Z0-9\-_\/\.]`.
* - Collapses a leading `//+` to a single `/` so the value can never
* be interpreted as a protocol-relative URL.
* - Prepends `/` if the (non-empty) result doesn't already start
* with one, so callers can always concatenate the value as an
* absolute path prefix.
* - Collapses a leading `//+` to a single `/` so the value can never be
* interpreted as a protocol-relative URL.
* - Prepends `/` if the (non-empty) result doesn't already start with one,
* so callers can always concatenate the value as an absolute path prefix.
* - Rejects values containing `..` segments.
*
* The output is always either the empty string or a string that starts
* with exactly one `/` and contains only `[A-Za-z0-9\-_./]`.
*/
export const sanitizeProxyPath = (req: {header: (n: string) => string|undefined} | string | undefined): string => {
const raw = typeof req === 'string'
? req
: req && typeof req.header === 'function'
? (req.header('x-proxy-path') || '')
: '';
const HEADER_NAMES = [
// [headerName, requiresTrustProxy]
['x-proxy-path', false] as const,
['x-forwarded-prefix', true] as const,
['x-ingress-path', true] as const,
];
const cleanOne = (raw: string): string => {
let cleaned = raw.replace(/[^a-zA-Z0-9\-_\/\.]/g, '');
if (!cleaned) return '';
// Collapse leading "//+" to a single "/" so the value can never be
// interpreted as a protocol-relative URL when concatenated into an
// href / Location / iframe src.
cleaned = cleaned.replace(/^\/{2,}/, '/');
// Ensure the value starts with exactly one "/". Several callers
// concatenate this as a URL-path prefix (e.g. `${proxyPath}/p/...`
// for redirects, `${proxyPath}/watch/...` for entrypoint URLs) and
// assume the value is either empty or absolute. A header value like
// `pad/etherpad` would otherwise become a relative redirect /
// entrypoint and break the page.
if (cleaned[0] !== '/') cleaned = '/' + cleaned;
// Refuse "/.." / "../" segments — path-traversal shapes that some
// downstream URL joiners would still honour even after the character
// filter above.
if (/(?:^|\/)\.\.(?:\/|$)/.test(cleaned)) return '';
return cleaned;
};
type ReqLike = {header: (n: string) => string|undefined};
export const sanitizeProxyPath = (
req: ReqLike | string | undefined,
opts: {trustProxy?: boolean} = {},
): string => {
// String form preserves the original behaviour for callers that pre-extracted
// the value themselves (e.g. tests). It's treated as a raw value with no
// header-gating: the caller has already decided to use it.
if (typeof req === 'string') return cleanOne(req);
if (!req || typeof req.header !== 'function') return '';
const trustProxy = opts.trustProxy ?? !!settings.trustProxy;
for (const [name, requiresTrust] of HEADER_NAMES) {
if (requiresTrust && !trustProxy) continue;
const raw = req.header(name) || '';
const cleaned = cleanOne(raw);
if (cleaned) return cleaned;
}
return '';
};

View file

@ -139,21 +139,27 @@ const sanitizePublicURL = (raw: string | null | undefined): string | null => {
// Builds an absolute URL. Prefers settings.publicURL when configured (operator-
// trusted); otherwise falls back to the request's protocol+Host with strict
// host validation so a crafted Host header can't appear in og:url / og:image.
// proxyPath is prepended to pathname in the fallback path — it recovers the
// public URL prefix that the reverse proxy stripped before forwarding the
// request. Ignored when publicURL is set (publicURL encodes the canonical
// origin and any path component the operator wants).
const buildAbsoluteUrl = (
req: Request, pathname: string, publicURL: string | null | undefined,
proxyPath: string,
): string => {
const trusted = sanitizePublicURL(publicURL);
if (trusted) return `${trusted}${pathname}`;
const proto = req.protocol === 'https' ? 'https' : 'http';
const host = sanitizeHost(req.get && req.get('host')) || 'localhost';
return `${proto}://${host}${pathname}`;
return `${proto}://${host}${proxyPath}${pathname}`;
};
const resolveImageUrl = (
req: Request, faviconSetting: string | null | undefined, publicURL: string | null | undefined,
proxyPath: string,
): string => {
if (faviconSetting && /^https?:\/\//i.test(faviconSetting)) return faviconSetting;
return buildAbsoluteUrl(req, '/favicon.ico', publicURL);
return buildAbsoluteUrl(req, '/favicon.ico', publicURL, proxyPath);
};
export type RenderOpts = {
@ -163,6 +169,11 @@ export type RenderOpts = {
locales: {[lang: string]: {[key: string]: string}},
kind: 'pad' | 'timeslider' | 'home',
padName?: string,
// URL-path prefix Etherpad is being served under (`''` when running at root).
// When set, used as a path prefix for from-request fallback URLs. Ignored
// when settings.publicURL is configured (publicURL encodes the canonical
// origin and any path component the operator wants).
proxyPath?: string,
};
// Operator override wins when set. Settings.ts coerces env-var strings to
@ -189,7 +200,8 @@ export const renderSocialMeta = (o: RenderOpts): string => {
const description = resolveDescriptionWithOverride(
o.settings.socialMeta && o.settings.socialMeta.description,
o.locales, renderLang);
const imageUrl = resolveImageUrl(o.req, o.settings.favicon, o.settings.publicURL);
const proxyPath = o.proxyPath || '';
const imageUrl = resolveImageUrl(o.req, o.settings.favicon, o.settings.publicURL, proxyPath);
const imageAlt = `${siteName} logo`;
let title = siteName;
@ -203,7 +215,7 @@ export const renderSocialMeta = (o: RenderOpts): string => {
if (qIdx >= 0) pathname = pathname.slice(0, qIdx);
return buildSocialMetaHtml({
url: buildAbsoluteUrl(o.req, pathname, o.settings.publicURL),
url: buildAbsoluteUrl(o.req, pathname, o.settings.publicURL, proxyPath),
siteName,
title,
description,