Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,264 changes: 1,264 additions & 0 deletions docs/superpowers/plans/2026-05-18-issue-7802-url-base-path.md

Large diffs are not rendered by default.

Large diffs are not rendered by default.

9 changes: 9 additions & 0 deletions settings.json.template
Original file line number Diff line number Diff line change
Expand Up @@ -535,6 +535,15 @@
*
* The other effect will be that the logs will contain the real client's IP,
* instead of the reverse proxy's IP.
*
* 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. 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,

Expand Down
28 changes: 17 additions & 11 deletions src/node/hooks/express/pwa.ts
Original file line number Diff line number Diff line change
@@ -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();
}
};
17 changes: 15 additions & 2 deletions src/node/hooks/express/specialpages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}));
})
})

Expand All @@ -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,
Expand All @@ -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);
})
Expand Down Expand Up @@ -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,
Expand All @@ -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);
})
Expand Down Expand Up @@ -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}));
});


Expand All @@ -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,
Expand All @@ -391,6 +400,7 @@ exports.expressCreateServer = async (_hookName: string, args: ArgsExpressType, c
entrypoint: "../"+fileNamePad,
settings: settings.getPublicSettings(),
socialMetaHtml,
proxyPath,
})
res.send(content);
});
Expand All @@ -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,
Expand All @@ -424,6 +436,7 @@ exports.expressCreateServer = async (_hookName: string, args: ArgsExpressType, c
entrypoint: "../../"+fileNameTimeSlider,
settings: settings.getPublicSettings(),
socialMetaHtml,
proxyPath,
}));
});
} else {
Expand Down
1 change: 1 addition & 0 deletions src/node/utils/ExportHtml.ts
Original file line number Diff line number Diff line change
Expand Up @@ -532,6 +532,7 @@ exports.getPadHTMLDocument = async (padId: string, revNum: string, readOnlyId: n
body: html,
padId: Security.escapeHTML(readOnlyId || padId),
extraCSS: stylesForExportCSS,
proxyPath: '',
});
};

Expand Down
16 changes: 13 additions & 3 deletions src/node/utils/Settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
81 changes: 49 additions & 32 deletions src/node/utils/sanitizeProxyPath.ts
Original file line number Diff line number Diff line change
@@ -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 '';
};
20 changes: 16 additions & 4 deletions src/node/utils/socialMeta.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -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
Expand All @@ -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;
Expand All @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion src/templates/export_html.html
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
<html lang="en">
<head>
<title><%- padId %></title>
<link rel="manifest" href="/manifest.json" />
<link rel="manifest" href="<%= typeof proxyPath !== 'undefined' ? proxyPath : '' %>/manifest.json" />
<meta name="generator" content="Etherpad"/>
<meta name="author" content="Etherpad"/>
<meta name="changedby" content="Etherpad"/>
Expand Down
4 changes: 2 additions & 2 deletions src/templates/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
<title><%=settings.title%></title>
<%- typeof socialMetaHtml !== 'undefined' ? socialMetaHtml : '' %>
<meta charset="utf-8">
<link rel="manifest" href="/manifest.json" />
<link rel="manifest" href="<%= typeof proxyPath !== 'undefined' ? proxyPath : '' %>/manifest.json" />
<meta name="referrer" content="no-referrer">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=0">
<link rel="shortcut icon" href="favicon.ico">
Expand Down Expand Up @@ -248,5 +248,5 @@ <h2 data-l10n-id="index.recentPads"></h2>
<% e.begin_block("indexCustomScripts"); %>
<script src="static/skins/<%=encodeURI(settings.skinName)%>/index.js?v=<%=settings.randomVersionString%>"></script>
<% e.end_block(); %>
<div style="display:none"><a href="/javascript" data-jslicense="1">JavaScript license information</a></div>
<div style="display:none"><a href="<%= typeof proxyPath !== 'undefined' ? proxyPath : '' %>/javascript" data-jslicense="1">JavaScript license information</a></div>
</html>
Loading
Loading