mirror of
https://github.com/ether/etherpad-lite.git
synced 2026-07-18 00:57:55 +00:00
* 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:
parent
4d998d6ef2
commit
86edd67f58
17 changed files with 2055 additions and 61 deletions
|
|
@ -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();
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -532,6 +532,7 @@ exports.getPadHTMLDocument = async (padId: string, revNum: string, readOnlyId: n
|
|||
body: html,
|
||||
padId: Security.escapeHTML(readOnlyId || padId),
|
||||
extraCSS: stylesForExportCSS,
|
||||
proxyPath: '',
|
||||
});
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 '';
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue