mirror of
https://github.com/ether/etherpad-lite.git
synced 2026-07-17 16:47:05 +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
1264
docs/superpowers/plans/2026-05-18-issue-7802-url-base-path.md
Normal file
1264
docs/superpowers/plans/2026-05-18-issue-7802-url-base-path.md
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,275 @@
|
|||
# URL base-path support (X-Forwarded-Prefix / X-Ingress-Path) — Design
|
||||
|
||||
GitHub issue: https://github.com/ether/etherpad/issues/7802
|
||||
|
||||
## Problem
|
||||
|
||||
Etherpad assumes it is served at `/`. When a reverse proxy adds a path prefix —
|
||||
Home Assistant ingress (`/api/hassio_ingress/<random-token>/`), Nginx
|
||||
`location /etherpad/`, Cloudflare Worker routes — three categories of URLs
|
||||
break:
|
||||
|
||||
1. Hard-coded leading-slash hrefs/srcs in server-rendered HTML (`/static/...`,
|
||||
`/admin/...`, `/manifest.json`, `/favicon.ico`).
|
||||
2. Plugin assets injected via the inner ace iframe and via plugin DOM hooks
|
||||
that emit leading-slash URLs.
|
||||
3. From-request absolute URLs in social-meta tags (`og:url`, `og:image`,
|
||||
`twitter:image`) — they pick up the bound listen address (e.g.
|
||||
`http://0.0.0.0:9001/...`) instead of the public origin.
|
||||
|
||||
Failures are partial and confusing: the pad loads, the toolbar renders, the
|
||||
pad text round-trips through the server, but plugin CSS 404s and admin pages
|
||||
are unreachable from inside the proxied iframe.
|
||||
|
||||
The `publicURL` setting can't fix this: HA ingress prefixes are per-session
|
||||
random tokens, not stable values. Etherpad has to learn the prefix from each
|
||||
request.
|
||||
|
||||
## Goals
|
||||
|
||||
- When `settings.trustProxy === true` and a proxied request carries an
|
||||
`X-Forwarded-Prefix` or `X-Ingress-Path` header, Etherpad emits every
|
||||
asset URL, admin link, manifest reference, and socket.io endpoint under
|
||||
that prefix.
|
||||
- The HTML page also carries `<base href="${prefix}/">` so plugin-injected
|
||||
leading-slash URLs route through the prefix without each plugin opting in.
|
||||
- Behavior with no proxy header (or with `trustProxy === false`) is byte-for-
|
||||
byte identical to today.
|
||||
- No new `settings.json` field.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Static `settings.basePath` configuration. Rejected because HA ingress is
|
||||
per-session, and proxies that want a stable prefix can already set the
|
||||
header. (Confirmed during brainstorming.)
|
||||
- Deprecating `publicURL`. It remains the canonical-origin setting for
|
||||
Open Graph / Twitter Card absolute URLs; basePath is orthogonal.
|
||||
- Express mount-path rewiring. Proxies strip the prefix before forwarding;
|
||||
Etherpad still routes against `/p/...`.
|
||||
- "No link to /admin from index" UX symptom mentioned in the issue. Out of
|
||||
scope; can be filed separately as an admin discoverability ticket.
|
||||
|
||||
## Header handling
|
||||
|
||||
Honored only when `settings.trustProxy === true`. Otherwise the parser
|
||||
returns `''`.
|
||||
|
||||
Headers checked in this order; first non-empty wins:
|
||||
|
||||
1. `X-Forwarded-Prefix` (HAProxy / Traefik convention)
|
||||
2. `X-Ingress-Path` (Home Assistant convention)
|
||||
|
||||
Sanitization, in order:
|
||||
|
||||
1. Trim whitespace.
|
||||
2. If non-empty and not starting with `/`, prepend `/`.
|
||||
3. Strip trailing slashes.
|
||||
4. Reject (return `''`) on any of: `..`, `://`, `\`, control characters,
|
||||
or any character outside `[A-Za-z0-9_\-./~%]`.
|
||||
5. Reject if length > 1024 chars.
|
||||
|
||||
Result is a plain string: `''` (no prefix) or `/some/prefix` (no trailing
|
||||
slash, starts with `/`).
|
||||
|
||||
`''` is the sentinel for "no prefix" everywhere downstream.
|
||||
|
||||
## Architecture
|
||||
|
||||
Single source of truth — `req.basePath` — set once per request, propagated
|
||||
to three sinks:
|
||||
|
||||
```
|
||||
X-Forwarded-Prefix / X-Ingress-Path
|
||||
│
|
||||
▼
|
||||
parseBasePath() in middleware
|
||||
│
|
||||
req.basePath = "/foo"
|
||||
│
|
||||
┌──────────────────────────────┼──────────────────────────────┐
|
||||
▼ ▼ ▼
|
||||
EJS / res.locals.basePath clientVars.basePath <base href="${basePath}/">
|
||||
│ │ │
|
||||
templates emit pad.ts sets baseURL plugin-injected
|
||||
<%= basePath %>/... socket.io path follows leading-slash URLs
|
||||
for every leading socialMeta builds resolve through prefix
|
||||
slash href/src from-request URLs with (belt-and-braces)
|
||||
prefix
|
||||
```
|
||||
|
||||
Backwards compatible by construction: no header → `req.basePath = ''` →
|
||||
templates emit `/static/...` (today's exact output) → `<base href="/">` is a
|
||||
no-op.
|
||||
|
||||
## Pre-existing infrastructure (discovered during plan write-up)
|
||||
|
||||
The codebase already has substantial proxy-path support that wasn't visible
|
||||
from the issue text. The plan extends rather than replaces:
|
||||
|
||||
- `src/node/utils/sanitizeProxyPath.ts` — already reads `x-proxy-path`,
|
||||
sanitizes the value, returns `''` or `/...`. Same shape as the spec's
|
||||
proposed `parseBasePath`. Plan: extend the header list to also check
|
||||
`X-Ingress-Path` and `X-Forwarded-Prefix`, gated on `trustProxy`.
|
||||
- `src/templates/padBootstrap.js` and `timeSliderBootstrap.js` — already
|
||||
compute `basePath` from `window.location` and set `pad.baseURL`,
|
||||
`window.plugins.baseURL`, `timeSlider.baseURL`. socket.io path
|
||||
(`socketio.connect(exports.baseURL, ...)`) and the `fetch` call in
|
||||
`pad.ts:1040` already follow the prefix without any change.
|
||||
- `src/node/hooks/express/admin.ts` — already substitutes `/admin` and
|
||||
`/socket.io` in the admin SPA HTML/JS/CSS using `sanitizeProxyPath`.
|
||||
No admin Vite rebuild needed.
|
||||
- `src/templates/pad.html`, `timeslider.html` — already use relative
|
||||
paths (`../static/...`, `../favicon.ico`, `../../manifest.json`), so
|
||||
they pick up the prefix naturally when served behind one.
|
||||
- `src/node/hooks/express/specialpages.ts` — already calls
|
||||
`sanitizeProxyPath(req)` and threads `proxyPath` into the rendered
|
||||
`entrypoint` URL.
|
||||
|
||||
What is NOT covered today, and forms the plan's actual scope:
|
||||
|
||||
1. **Header source list** — HA Ingress sends `X-Ingress-Path`; nginx
|
||||
subpath users typically rely on `X-Forwarded-Prefix`. Etherpad only
|
||||
reads `x-proxy-path`. Mismatched header → no prefix → all symptoms.
|
||||
2. **`/manifest.json` icons** (`src/node/hooks/express/pwa.ts`) emit
|
||||
hard-coded `/favicon.ico` and `/static/skins/...` paths.
|
||||
3. **`socialMeta` from-request URLs** (`src/node/utils/socialMeta.ts`)
|
||||
don't honor `proxyPath` when building the from-request fallback,
|
||||
producing wrong `og:url` / `og:image` under a prefix.
|
||||
4. **Leading-slash URLs in `index.html`, `timeslider.html`, `pad.html`,
|
||||
`export_html.html`** — manifest link, jslicense link, reconnect
|
||||
form action. Each can be made relative or `proxyPath`-prefixed.
|
||||
5. **Plugin DOM injection** — plugins that emit `<link href="/static/...">`
|
||||
at runtime aren't covered by any existing rewrite. A `<base href>`
|
||||
tag was considered as a belt-and-braces fix but rejected: path-absolute
|
||||
URLs (`/foo`) deliberately ignore the path component of `<base href>` and
|
||||
resolve against the origin, so `<base href="/sub/">` + `<link href="/static/foo">`
|
||||
still resolves to `/static/foo`. And `<base href>` would change the
|
||||
resolution base for existing relative URLs in `pad.html` /
|
||||
`timeslider.html` (e.g. `../static/css/pad.css`), breaking them. Plugin
|
||||
authors emitting leading-slash URLs need to use `clientVars`-derived or
|
||||
relative paths — documented separately as a plugin guidance issue, not
|
||||
resolved here.
|
||||
6. **Settings documentation** — `settings.json.template` and `Settings.ts`
|
||||
doc comment for `trustProxy` need to mention the new header sources.
|
||||
|
||||
## Components
|
||||
|
||||
Reflects the discovery above — extends existing helpers; smaller surface than the original draft.
|
||||
|
||||
| File | Change |
|
||||
|---|---|
|
||||
| `src/node/utils/sanitizeProxyPath.ts` | Extend header source list to also read `x-forwarded-prefix` and `x-ingress-path`. Standard headers (everything other than the existing `x-proxy-path`) gated on `settings.trustProxy === true`. First non-empty wins, after sanitization. |
|
||||
| `src/node/hooks/express/pwa.ts` | `/manifest.json` handler reads `sanitizeProxyPath(req)` and emits `${proxyPath}/favicon.ico`, `${proxyPath}/static/skins/...`, `${proxyPath}/` for `start_url`. Mark response `Vary: x-proxy-path, x-ingress-path, x-forwarded-prefix` + `Cache-Control: private, no-store` when proxyPath is non-empty (mirrors the admin handler's pattern). |
|
||||
| `src/node/utils/socialMeta.ts` | `buildAbsoluteUrl` honors `proxyPath` when falling back to from-request origin: `${origin}${proxyPath}${pathname}`. `publicURL` still wins when set. |
|
||||
| `src/templates/index.html` | Replace `<link rel="manifest" href="/manifest.json">` and the jslicense `<a href="/javascript">` with `proxyPath`-prefixed values. Route handler in `specialpages.ts` passes `proxyPath` as an explicit template variable. |
|
||||
| `src/templates/timeslider.html`, `pad.html` | jslicense `<a href>` and `<form action="/ep/pad/reconnect">` use `proxyPath`. Fix pre-existing `..`-count bug in the manifest `<link>` (`../../manifest.json` in pad.html resolves under a prefix as `/manifest.json` instead of `/sub/manifest.json`; ditto `../../../manifest.json` in timeslider). Reduce by one to make the path correct in both root-mount and prefix-mount cases. |
|
||||
| `src/templates/export_html.html` | `<link rel="manifest">` uses proxyPath via the export route's render context. |
|
||||
| `src/node/hooks/express/specialpages.ts` + export route | Pass `proxyPath` into every `eejs.require` call that renders the affected templates. |
|
||||
| `settings.json.template` + `Settings.ts` doc comment | Document the three honored header names and the trustProxy gate. No new field. |
|
||||
|
||||
Out: no new `basePath.ts`, no Express middleware, no EJS-context-wide helper, no admin Vite rebuild, no `clientVars.basePath`, no edits to `pad.ts` / `timeslider.ts` / `padBootstrap.js`. Pre-existing code already covers those surfaces (`pad.baseURL` and `window.plugins.baseURL` are derived client-side from `window.location` in `padBootstrap.js` / `timeSliderBootstrap.js`).
|
||||
|
||||
## Data flow — concrete example
|
||||
|
||||
Home Assistant ingress request:
|
||||
|
||||
```
|
||||
GET /p/scratch HTTP/1.1
|
||||
Host: 0.0.0.0:9001
|
||||
X-Forwarded-Proto: https
|
||||
X-Forwarded-Host: ha.example
|
||||
X-Forwarded-Prefix: /api/hassio_ingress/abc123
|
||||
X-Ingress-Path: /api/hassio_ingress/abc123
|
||||
```
|
||||
|
||||
1. `app.enable('trust proxy')` → `req.protocol === 'https'`, `req.hostname === 'ha.example'`.
|
||||
2. `sanitizeProxyPath(req)` → `'/api/hassio_ingress/abc123'` (read from `X-Ingress-Path` because trustProxy is on; would also accept `X-Forwarded-Prefix`).
|
||||
3. `specialpages.ts` route handler for `/p/:pad`: renders `pad.html` with `proxyPath` in the template context. Output includes:
|
||||
- jslicense `<a href>` and reconnect form `action` prefixed via the EJS variable.
|
||||
- `<link rel="manifest" href="../manifest.json">` (fixed from `../../manifest.json` — see Risks): resolves to `/api/hassio_ingress/abc123/manifest.json`.
|
||||
- `<link href="../static/css/pad.css...">` is already relative — resolves under the prefix to `/api/hassio_ingress/abc123/static/css/pad.css`.
|
||||
4. Browser fetches `/api/hassio_ingress/abc123/manifest.json` → `pwa.ts` route emits manifest with all icon `src` values prefixed; `start_url` prefixed.
|
||||
5. Browser establishes socket.io: client-side `padBootstrap.js` computes `basePath = new URL('..', window.location).pathname` → `/api/hassio_ingress/abc123/` → `pad.baseURL` set → `socketio.connect('/api/hassio_ingress/abc123/', ...)` → socket.io path is `/api/hassio_ingress/abc123/socket.io/`. No code change here — pre-existing logic.
|
||||
6. `socialMeta`: `og:url = https://ha.example/api/hassio_ingress/abc123/p/scratch`, `og:image = https://ha.example/api/hassio_ingress/abc123/favicon.ico`.
|
||||
7. Inner ace iframe loads `../static/empty.html` (relative) → resolves to `/api/hassio_ingress/abc123/static/empty.html` naturally. No code change in the inner iframe; plugin CSS injected via `aceEditorCSS` is also relative-prefixed (`../static/plugins/...`).
|
||||
|
||||
Direct (non-proxied) request — same code path:
|
||||
|
||||
1. No `x-proxy-path`, no `X-Forwarded-Prefix`, no `X-Ingress-Path` → `sanitizeProxyPath(req) === ''`.
|
||||
2. Templates render today's output. The reduced-`..`-count manifest link still resolves to `/manifest.json` from a root-mount pad URL (`/p/test`), so no observable change for non-proxied users.
|
||||
3. `pwa.ts` returns today's manifest (icon srcs `'/favicon.ico'` etc.) when `proxyPath === ''`.
|
||||
|
||||
## Backwards compatibility
|
||||
|
||||
- Existing deployments: no header → behavior unchanged. `<base href="/">` is
|
||||
benign (HTML5 valid, no-op for absolute URLs).
|
||||
- `publicURL` semantics unchanged. When set, `socialMeta` still uses it for
|
||||
the origin in OG tags; basePath only affects request-derived fallbacks.
|
||||
- Plugins using `clientVars.padId`, etc. continue to work. Plugins that build
|
||||
asset URLs by hardcoding `/static/...` now route through the prefix via the
|
||||
`<base href>` belt-and-braces, even if they don't read `clientVars.basePath`.
|
||||
- No new dependency, no new setting, no migration step.
|
||||
|
||||
## Risks
|
||||
|
||||
- **Plugin templates and plugin-rendered HTML** outside `src/templates/` may
|
||||
contain leading-slash URLs. We cannot auto-rewrite them. `<base href>`
|
||||
was considered as a runtime catch-all and rejected (see "Plugin DOM
|
||||
injection" above). Plugins emitting absolute URLs should prefer relative
|
||||
or `clientVars`-derived paths; documenting that recommendation is a
|
||||
separate follow-up.
|
||||
- **Manifest `..`-count fix is a strict improvement.** Today's
|
||||
`../../manifest.json` in `pad.html` resolves to `/manifest.json` from a
|
||||
root-mount pad URL — a happy accident: the relative path has one `..` too
|
||||
many but the browser silently caps `..` at the path root. After the fix
|
||||
to `../manifest.json`, the result is `/manifest.json` from root and
|
||||
`/<prefix>/manifest.json` under a prefix. No regression possible at root.
|
||||
Same logic for `timeslider.html`'s `../../../manifest.json`.
|
||||
- **Malicious header injection** when `trustProxy === false` is irrelevant
|
||||
(we ignore the headers). When `trustProxy === true` the operator has
|
||||
already declared the proxy trusted; sanitization (rejects `..`, scheme,
|
||||
control chars, etc.) prevents XSS via `<base href>` and prevents path
|
||||
escape. We do NOT trust headers in non-proxy mode.
|
||||
|
||||
## Testing
|
||||
|
||||
- **Unit** — `src/tests/backend/specs/basePath-unit.ts`:
|
||||
- `parseBasePath` truth table: trustProxy off → `''`; no headers → `''`;
|
||||
`X-Forwarded-Prefix: /foo` → `/foo`; `X-Forwarded-Prefix: foo` →
|
||||
`/foo`; `X-Forwarded-Prefix: /foo/` → `/foo`; `X-Forwarded-Prefix:
|
||||
/foo//` → `/foo`; `X-Forwarded-Prefix: /a/../b` → `''`; `X-Forwarded-Prefix:
|
||||
https://evil.example/foo` → `''`; `X-Forwarded-Prefix: ` (whitespace)
|
||||
→ `''`; `X-Forwarded-Prefix` empty + `X-Ingress-Path: /bar` → `/bar`;
|
||||
`X-Forwarded-Prefix: /a` + `X-Ingress-Path: /b` → `/a` (first wins);
|
||||
long-string > 1024 chars → `''`; non-ASCII → `''`.
|
||||
- **Backend integration** — `src/tests/backend/specs/basePath-integration.ts`:
|
||||
- supertest GET `/` with `X-Forwarded-Prefix: /api/foo` (and trustProxy
|
||||
on): rendered HTML contains `<base href="/api/foo/">`, contains
|
||||
`href="/api/foo/static/...`, contains `href="/api/foo/manifest.json"`.
|
||||
- GET `/p/test` same expectations.
|
||||
- GET `/admin/`: assert prefix is present in `<link>`/`<script>` paths and `<base href>` present. The exact mechanism (Vite `base: './'` rebuild vs. server-side templating of the admin HTML) is chosen during implementation; the test only asserts the post-render output.
|
||||
- GET `/` with same headers but trustProxy OFF: no prefix anywhere, output
|
||||
matches the trustProxy-on-no-headers output.
|
||||
- GET `/` with `X-Forwarded-Prefix: /a/../b`: no prefix (rejected),
|
||||
output identical to no-headers.
|
||||
- **socialMeta** — extend existing `src/tests/backend/specs/socialMeta-unit.ts`:
|
||||
- With `req.basePath = '/api/foo'` and no `publicURL`: `og:url` and
|
||||
`og:image` carry the prefix.
|
||||
- With `publicURL` set: `publicURL` still wins (existing test); basePath
|
||||
not applied (publicURL is assumed to encode the full canonical origin
|
||||
including any path component).
|
||||
- **No new E2E.** Existing Playwright suite covers normal URLs. Adding a
|
||||
reverse-proxy harness for E2E is high-effort for low marginal coverage;
|
||||
manual verification through the HA addon during the release candidate
|
||||
phase is sufficient.
|
||||
|
||||
## Open questions
|
||||
|
||||
None at design time. Implementation plan will resolve:
|
||||
|
||||
- Exact mechanism to thread `basePath` from HTTP request to socket.io
|
||||
handshake (existing socket handshake already attaches the original
|
||||
request; need to confirm the access pattern in `PadMessageHandler`).
|
||||
- Whether `admin/vite.config.ts` rebuild produces a stable hash filename
|
||||
or whether we need to also adjust the admin route handler to scan the
|
||||
built `dist/` directory at startup.
|
||||
|
|
@ -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,
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"/>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
@ -248,5 +248,5 @@
|
|||
<% 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>
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@
|
|||
<% e.end_block(); %>
|
||||
<title><%=settings.title%></title>
|
||||
<%- typeof socialMetaHtml !== 'undefined' ? socialMetaHtml : '' %>
|
||||
<link rel="manifest" href="../../manifest.json" />
|
||||
<link rel="manifest" href="../manifest.json" />
|
||||
<script>
|
||||
/*
|
||||
|@licstart The following is the entire license notice for the
|
||||
|
|
@ -515,7 +515,7 @@
|
|||
<button id="forcereconnect" class="btn btn-primary" data-l10n-id="pad.modals.forcereconnect"></button>
|
||||
<% e.end_block(); %>
|
||||
</div>
|
||||
<form id="reconnectform" method="post" action="/ep/pad/reconnect" accept-charset="UTF-8" style="display: none;">
|
||||
<form id="reconnectform" method="post" action="<%= typeof proxyPath !== 'undefined' ? proxyPath : '' %>/ep/pad/reconnect" accept-charset="UTF-8" style="display: none;">
|
||||
<input type="hidden" class="padId" name="padId">
|
||||
<input type="hidden" class="diagnosticInfo" name="diagnosticInfo">
|
||||
<input type="hidden" class="missedChanges" name="missedChanges">
|
||||
|
|
@ -661,7 +661,7 @@
|
|||
<% e.begin_block("customScripts"); %>
|
||||
<script type="text/javascript" src="../static/skins/<%=encodeURI(settings.skinName)%>/pad.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>
|
||||
<% e.end_block(); %>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@
|
|||
*/
|
||||
</script>
|
||||
<meta charset="utf-8">
|
||||
<link rel="manifest" href="../../../manifest.json" />
|
||||
<link rel="manifest" href="../../manifest.json" />
|
||||
<meta name="robots" content="noindex, nofollow">
|
||||
<meta name="referrer" content="no-referrer">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=0">
|
||||
|
|
@ -220,7 +220,7 @@
|
|||
<button id="forcereconnect" class="btn btn-primary" data-l10n-id="pad.modals.forcereconnect"></button>
|
||||
<% e.end_block(); %>
|
||||
</div>
|
||||
<form id="reconnectform" method="post" action="/ep/pad/reconnect" accept-charset="UTF-8" style="display: none;">
|
||||
<form id="reconnectform" method="post" action="<%= typeof proxyPath !== 'undefined' ? proxyPath : '' %>/ep/pad/reconnect" accept-charset="UTF-8" style="display: none;">
|
||||
<input type="hidden" class="padId" name="padId">
|
||||
<input type="hidden" class="diagnosticInfo" name="diagnosticInfo">
|
||||
<input type="hidden" class="missedChanges" name="missedChanges">
|
||||
|
|
@ -280,5 +280,5 @@
|
|||
<!-- Bootstrap -->
|
||||
<script src="<%=entrypoint%>"></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>
|
||||
|
|
|
|||
|
|
@ -121,4 +121,88 @@ describe('sanitizeProxyPath', () => {
|
|||
expect(sanitizeProxyPath('//pad')).toBe('/pad');
|
||||
});
|
||||
});
|
||||
|
||||
describe('X-Forwarded-Prefix and X-Ingress-Path', () => {
|
||||
const mockReqMulti = (headers: Record<string, string|undefined>) => ({
|
||||
header: (name: string) => headers[name.toLowerCase()],
|
||||
});
|
||||
|
||||
it('reads X-Forwarded-Prefix when trustProxy is true', () => {
|
||||
expect(sanitizeProxyPath(
|
||||
mockReqMulti({'x-forwarded-prefix': '/foo'}),
|
||||
{trustProxy: true})).toBe('/foo');
|
||||
});
|
||||
|
||||
it('reads X-Ingress-Path when trustProxy is true', () => {
|
||||
expect(sanitizeProxyPath(
|
||||
mockReqMulti({'x-ingress-path': '/api/hassio_ingress/abc'}),
|
||||
{trustProxy: true})).toBe('/api/hassio_ingress/abc');
|
||||
});
|
||||
|
||||
it('ignores X-Forwarded-Prefix when trustProxy is false', () => {
|
||||
expect(sanitizeProxyPath(
|
||||
mockReqMulti({'x-forwarded-prefix': '/foo'}),
|
||||
{trustProxy: false})).toBe('');
|
||||
});
|
||||
|
||||
it('ignores X-Ingress-Path when trustProxy is false', () => {
|
||||
expect(sanitizeProxyPath(
|
||||
mockReqMulti({'x-ingress-path': '/foo'}),
|
||||
{trustProxy: false})).toBe('');
|
||||
});
|
||||
|
||||
it('x-proxy-path still works without trustProxy (legacy Etherpad convention)', () => {
|
||||
expect(sanitizeProxyPath(
|
||||
mockReqMulti({'x-proxy-path': '/legacy'}),
|
||||
{trustProxy: false})).toBe('/legacy');
|
||||
});
|
||||
|
||||
it('x-proxy-path wins over standard headers when all are present', () => {
|
||||
expect(sanitizeProxyPath(
|
||||
mockReqMulti({
|
||||
'x-proxy-path': '/legacy',
|
||||
'x-forwarded-prefix': '/forwarded',
|
||||
'x-ingress-path': '/ingress',
|
||||
}),
|
||||
{trustProxy: true})).toBe('/legacy');
|
||||
});
|
||||
|
||||
it('x-forwarded-prefix beats x-ingress-path when both are present', () => {
|
||||
expect(sanitizeProxyPath(
|
||||
mockReqMulti({
|
||||
'x-forwarded-prefix': '/forwarded',
|
||||
'x-ingress-path': '/ingress',
|
||||
}),
|
||||
{trustProxy: true})).toBe('/forwarded');
|
||||
});
|
||||
|
||||
it('sanitises standard headers the same as x-proxy-path', () => {
|
||||
expect(sanitizeProxyPath(
|
||||
mockReqMulti({'x-forwarded-prefix': '//evil.example/pwn'}),
|
||||
{trustProxy: true})).toBe('/evil.example/pwn');
|
||||
expect(sanitizeProxyPath(
|
||||
mockReqMulti({'x-ingress-path': '/a/../b'}),
|
||||
{trustProxy: true})).toBe('');
|
||||
expect(sanitizeProxyPath(
|
||||
mockReqMulti({'x-forwarded-prefix': 'pad'}),
|
||||
{trustProxy: true})).toBe('/pad');
|
||||
});
|
||||
|
||||
it('defaults trustProxy from settings when opts not provided', async () => {
|
||||
const settings = (await import('../../../node/utils/Settings')).default;
|
||||
const original = settings.trustProxy;
|
||||
try {
|
||||
settings.trustProxy = true;
|
||||
expect(sanitizeProxyPath(
|
||||
mockReqMulti({'x-forwarded-prefix': '/x'})))
|
||||
.toBe('/x');
|
||||
settings.trustProxy = false;
|
||||
expect(sanitizeProxyPath(
|
||||
mockReqMulti({'x-forwarded-prefix': '/x'})))
|
||||
.toBe('');
|
||||
} finally {
|
||||
settings.trustProxy = original;
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
97
src/tests/backend/specs/pwaManifest.ts
Normal file
97
src/tests/backend/specs/pwaManifest.ts
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
'use strict';
|
||||
|
||||
/**
|
||||
* Coverage for /manifest.json prefix-awareness.
|
||||
*
|
||||
* Without a proxy header the manifest should emit today's values
|
||||
* (leading-slash absolute paths). With a sanitised `x-proxy-path`,
|
||||
* `x-forwarded-prefix` (requires trustProxy) or `x-ingress-path`
|
||||
* (requires trustProxy), the manifest should emit prefixed paths so
|
||||
* the PWA renders icons and start_url correctly when Etherpad is
|
||||
* proxied under a subpath.
|
||||
*/
|
||||
|
||||
const common = require('../common');
|
||||
import settings from '../../../node/utils/Settings';
|
||||
|
||||
let agent: any;
|
||||
|
||||
describe(__filename, function () {
|
||||
before(async function () { agent = await common.init(); });
|
||||
|
||||
describe('/manifest.json without proxy headers', function () {
|
||||
it('emits leading-slash icon srcs and start_url=/', async function () {
|
||||
const res = await agent.get('/manifest.json').expect(200);
|
||||
const m = res.body;
|
||||
if (m.start_url !== '/') {
|
||||
throw new Error(`expected start_url "/", got ${JSON.stringify(m.start_url)}`);
|
||||
}
|
||||
const srcs = (m.icons || []).map((i: any) => i.src);
|
||||
for (const s of srcs) {
|
||||
if (!s.startsWith('/')) {
|
||||
throw new Error(`expected leading-slash icon src, got ${s}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('/manifest.json with x-proxy-path', function () {
|
||||
it('prefixes every icon src and start_url', async function () {
|
||||
const res = await agent.get('/manifest.json')
|
||||
.set('x-proxy-path', '/sub')
|
||||
.expect(200);
|
||||
const m = res.body;
|
||||
if (m.start_url !== '/sub/') {
|
||||
throw new Error(`expected start_url "/sub/", got ${JSON.stringify(m.start_url)}`);
|
||||
}
|
||||
const srcs = (m.icons || []).map((i: any) => i.src);
|
||||
for (const s of srcs) {
|
||||
if (!s.startsWith('/sub/')) {
|
||||
throw new Error(`expected /sub/-prefixed icon src, got ${s}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('sets Vary so caches don\'t collapse responses across prefixes', async function () {
|
||||
const res = await agent.get('/manifest.json')
|
||||
.set('x-proxy-path', '/sub')
|
||||
.expect(200);
|
||||
const vary = (res.headers.vary || '').toLowerCase();
|
||||
if (!vary.includes('x-proxy-path')) {
|
||||
throw new Error(`expected Vary to include x-proxy-path, got ${vary}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('/manifest.json with x-ingress-path (HA)', function () {
|
||||
it('ignores the header when trustProxy is off', async function () {
|
||||
const original = settings.trustProxy;
|
||||
settings.trustProxy = false;
|
||||
try {
|
||||
const res = await agent.get('/manifest.json')
|
||||
.set('x-ingress-path', '/api/hassio_ingress/abc')
|
||||
.expect(200);
|
||||
if (res.body.start_url !== '/') {
|
||||
throw new Error(`expected start_url "/" when trustProxy=false, got ${res.body.start_url}`);
|
||||
}
|
||||
} finally {
|
||||
settings.trustProxy = original;
|
||||
}
|
||||
});
|
||||
|
||||
it('honors the header when trustProxy is on', async function () {
|
||||
const original = settings.trustProxy;
|
||||
settings.trustProxy = true;
|
||||
try {
|
||||
const res = await agent.get('/manifest.json')
|
||||
.set('x-ingress-path', '/api/hassio_ingress/abc')
|
||||
.expect(200);
|
||||
if (res.body.start_url !== '/api/hassio_ingress/abc/') {
|
||||
throw new Error(`expected prefixed start_url, got ${res.body.start_url}`);
|
||||
}
|
||||
} finally {
|
||||
settings.trustProxy = original;
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -427,4 +427,81 @@ describe(__filename, function () {
|
|||
assert.ok(html.includes('"'), 'quote escaped');
|
||||
});
|
||||
});
|
||||
|
||||
describe('renderSocialMeta — proxyPath fallback (no publicURL)', function () {
|
||||
const mkReq = (overrides: Record<string, any> = {}) => ({
|
||||
protocol: 'https',
|
||||
get: (n: string) => n.toLowerCase() === 'host' ? 'pad.example' : undefined,
|
||||
acceptsLanguages: () => 'en',
|
||||
originalUrl: '/p/scratch',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
it('prefixes og:url with proxyPath when publicURL is null', function () {
|
||||
const out = renderSocialMeta({
|
||||
req: mkReq() as any,
|
||||
settings: {title: 'Etherpad', favicon: null, publicURL: null},
|
||||
availableLangs: {en: {}},
|
||||
locales: {en: {}},
|
||||
kind: 'pad',
|
||||
padName: 'scratch',
|
||||
proxyPath: '/api/hassio_ingress/abc',
|
||||
});
|
||||
if (!out.includes('content="https://pad.example/api/hassio_ingress/abc/p/scratch"')) {
|
||||
throw new Error(`og:url missing proxyPath prefix:\n${out}`);
|
||||
}
|
||||
});
|
||||
|
||||
it('prefixes og:image with proxyPath when publicURL is null and favicon is not an absolute URL', function () {
|
||||
const out = renderSocialMeta({
|
||||
req: mkReq() as any,
|
||||
settings: {title: 'Etherpad', favicon: null, publicURL: null},
|
||||
availableLangs: {en: {}},
|
||||
locales: {en: {}},
|
||||
kind: 'pad',
|
||||
padName: 'scratch',
|
||||
proxyPath: '/sub',
|
||||
});
|
||||
if (!out.includes('content="https://pad.example/sub/favicon.ico"')) {
|
||||
throw new Error(`og:image missing proxyPath prefix:\n${out}`);
|
||||
}
|
||||
});
|
||||
|
||||
it('publicURL still wins over proxyPath when both are set', function () {
|
||||
const out = renderSocialMeta({
|
||||
req: mkReq() as any,
|
||||
settings: {
|
||||
title: 'Etherpad',
|
||||
favicon: null,
|
||||
publicURL: 'https://pad.canonical.example',
|
||||
},
|
||||
availableLangs: {en: {}},
|
||||
locales: {en: {}},
|
||||
kind: 'pad',
|
||||
padName: 'scratch',
|
||||
proxyPath: '/sub',
|
||||
});
|
||||
if (!out.includes('content="https://pad.canonical.example/p/scratch"')) {
|
||||
throw new Error(`publicURL should win over proxyPath:\n${out}`);
|
||||
}
|
||||
if (out.includes('/sub/')) {
|
||||
throw new Error(`proxyPath leaked into URL when publicURL was set:\n${out}`);
|
||||
}
|
||||
});
|
||||
|
||||
it('proxyPath default of "" produces today\'s URL shape', function () {
|
||||
const out = renderSocialMeta({
|
||||
req: mkReq() as any,
|
||||
settings: {title: 'Etherpad', favicon: null, publicURL: null},
|
||||
availableLangs: {en: {}},
|
||||
locales: {en: {}},
|
||||
kind: 'pad',
|
||||
padName: 'scratch',
|
||||
// proxyPath omitted
|
||||
});
|
||||
if (!out.includes('content="https://pad.example/p/scratch"')) {
|
||||
throw new Error(`default URL shape regressed:\n${out}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
129
src/tests/backend/specs/urlBasePath.ts
Normal file
129
src/tests/backend/specs/urlBasePath.ts
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
'use strict';
|
||||
|
||||
/**
|
||||
* End-to-end coverage for X-Forwarded-Prefix / X-Ingress-Path support (#7802).
|
||||
*
|
||||
* Verifies that across the public surfaces:
|
||||
* - /
|
||||
* - /p/:pad
|
||||
* - /manifest.json
|
||||
*
|
||||
* a single sanitised proxy-path is reflected consistently in the
|
||||
* rendered HTML and JSON: <link rel="manifest">, og:url, og:image,
|
||||
* manifest start_url, manifest icon srcs, reconnect form action.
|
||||
*
|
||||
* Also verifies the no-header case still produces today's output
|
||||
* (regression guard).
|
||||
*/
|
||||
|
||||
const common = require('../common');
|
||||
import settings from 'ep_etherpad-lite/node/utils/Settings';
|
||||
|
||||
let agent: any;
|
||||
|
||||
const expectHas = (haystack: string, needle: string, label: string) => {
|
||||
if (!haystack.includes(needle)) {
|
||||
throw new Error(`expected ${label} to include ${JSON.stringify(needle)}.\n--- got ---\n${haystack.slice(0, 800)}\n...`);
|
||||
}
|
||||
};
|
||||
|
||||
const expectMisses = (haystack: string, needle: string, label: string) => {
|
||||
if (haystack.includes(needle)) {
|
||||
throw new Error(`${label} should not include ${JSON.stringify(needle)}.\n--- got ---\n${haystack.slice(0, 800)}\n...`);
|
||||
}
|
||||
};
|
||||
|
||||
describe(__filename, function () {
|
||||
before(async function () { agent = await common.init(); });
|
||||
|
||||
describe('no proxy headers - backwards compatibility', function () {
|
||||
it('/ renders today\'s URLs', async function () {
|
||||
const res = await agent.get('/').expect(200);
|
||||
expectHas(res.text, 'href="/manifest.json"', 'index manifest link');
|
||||
});
|
||||
|
||||
it('/p/:pad renders today\'s URLs', async function () {
|
||||
const res = await agent.get('/p/UrlBasePathTest').expect(200);
|
||||
expectHas(res.text, 'action="/ep/pad/reconnect"', 'reconnect form action');
|
||||
expectHas(res.text, 'href="../manifest.json"', 'manifest link (relative form)');
|
||||
});
|
||||
|
||||
it('/manifest.json returns root-relative paths', async function () {
|
||||
const res = await agent.get('/manifest.json').expect(200);
|
||||
if (res.body.start_url !== '/') {
|
||||
throw new Error(`expected "/", got ${res.body.start_url}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('with x-proxy-path: /sub', function () {
|
||||
const headers = {'x-proxy-path': '/sub'};
|
||||
|
||||
it('/ has /sub-prefixed manifest link', async function () {
|
||||
const res = await agent.get('/').set(headers).expect(200);
|
||||
expectHas(res.text, 'href="/sub/manifest.json"', 'index manifest link');
|
||||
expectMisses(res.text, 'href="/manifest.json"', 'unprefixed manifest link');
|
||||
});
|
||||
|
||||
it('/p/:pad reconnect form action carries the prefix', async function () {
|
||||
const res = await agent.get('/p/UrlBasePathTest').set(headers).expect(200);
|
||||
expectHas(res.text, 'action="/sub/ep/pad/reconnect"', 'reconnect form action');
|
||||
// The manifest <link> stays relative (../manifest.json); browser resolves
|
||||
// it to /sub/manifest.json based on the request URL - we assert the
|
||||
// template emits the relative form unchanged.
|
||||
expectHas(res.text, 'href="../manifest.json"', 'manifest link (relative form)');
|
||||
});
|
||||
|
||||
it('/p/:pad og:url and og:image carry the prefix', async function () {
|
||||
const res = await agent.get('/p/UrlBasePathTest').set(headers).expect(200);
|
||||
expectHas(res.text, '/sub/p/UrlBasePathTest', 'og:url path');
|
||||
expectHas(res.text, '/sub/favicon.ico', 'og:image path');
|
||||
});
|
||||
|
||||
it('/manifest.json has /sub-prefixed start_url and icon srcs', async function () {
|
||||
const res = await agent.get('/manifest.json').set(headers).expect(200);
|
||||
if (res.body.start_url !== '/sub/') {
|
||||
throw new Error(`expected /sub/, got ${res.body.start_url}`);
|
||||
}
|
||||
for (const icon of res.body.icons) {
|
||||
if (!icon.src.startsWith('/sub/')) {
|
||||
throw new Error(`icon src missing prefix: ${icon.src}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('with x-ingress-path under trustProxy', function () {
|
||||
const headers = {'x-ingress-path': '/api/hassio_ingress/abc'};
|
||||
let originalTrust: boolean;
|
||||
|
||||
before(function () {
|
||||
originalTrust = settings.trustProxy;
|
||||
settings.trustProxy = true;
|
||||
});
|
||||
after(function () { settings.trustProxy = originalTrust; });
|
||||
|
||||
it('/p/:pad picks up the HA ingress prefix in the reconnect form action', async function () {
|
||||
const res = await agent.get('/p/UrlBasePathTest').set(headers).expect(200);
|
||||
expectHas(res.text, 'action="/api/hassio_ingress/abc/ep/pad/reconnect"', 'reconnect form action');
|
||||
});
|
||||
|
||||
it('/manifest.json picks up the HA ingress prefix', async function () {
|
||||
const res = await agent.get('/manifest.json').set(headers).expect(200);
|
||||
if (res.body.start_url !== '/api/hassio_ingress/abc/') {
|
||||
throw new Error(`expected /api/hassio_ingress/abc/, got ${res.body.start_url}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('with x-ingress-path WITHOUT trustProxy', function () {
|
||||
const headers = {'x-ingress-path': '/api/hassio_ingress/abc'};
|
||||
|
||||
it('header is ignored - output is today\'s', async function () {
|
||||
// setUp guarantees trustProxy starts at its default (false) - see common.init
|
||||
const res = await agent.get('/p/UrlBasePathTest').set(headers).expect(200);
|
||||
expectHas(res.text, 'action="/ep/pad/reconnect"', 'unprefixed reconnect form action');
|
||||
expectMisses(res.text, '/api/hassio_ingress/', 'leaked ingress prefix');
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue