etherpad-lite/src/node/utils/sanitizeProxyPath.ts
John McLear 86edd67f58
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>
2026-05-18 13:27:59 +01:00

65 lines
2.6 KiB
TypeScript

import settings from './Settings';
/**
* Sanitize the URL-path prefix Etherpad is being served under.
*
* 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.
*
* 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.
* - 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\-_./]`.
*/
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 '';
cleaned = cleaned.replace(/^\/{2,}/, '/');
if (cleaned[0] !== '/') cleaned = '/' + cleaned;
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 '';
};