Skip to content

Commit 0fc3dc0

Browse files
author
Etherpad Release Bot
committed
Merge branch 'develop'
2 parents e57ee40 + 2db605d commit 0fc3dc0

53 files changed

Lines changed: 3347 additions & 478 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.github/workflows/installer-test.yml‎

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -111,8 +111,16 @@ jobs:
111111
fi
112112
113113
installer-windows:
114-
name: end-to-end install (windows-latest)
114+
name: end-to-end install (windows-latest, ${{ matrix.shell }})
115115
runs-on: windows-latest
116+
strategy:
117+
fail-fast: false
118+
matrix:
119+
# pwsh = PowerShell 7; powershell = Windows PowerShell 5.1, the
120+
# default shell on Windows 10/11 and what most `irm | iex` users run.
121+
# 5.1 passes native-command arguments differently (it strips embedded
122+
# double quotes), so the installer must be exercised under both (#8214).
123+
shell: [pwsh, powershell]
116124
steps:
117125
- uses: actions/checkout@v7
118126

@@ -123,7 +131,9 @@ jobs:
123131
- name: Pre-install pnpm
124132
run: npm install -g pnpm
125133

126-
- name: Run bin/installer.ps1 against this commit
134+
# `shell:` can't take an expression, so one step per shell.
135+
- name: Run bin/installer.ps1 against this commit (PowerShell 7)
136+
if: matrix.shell == 'pwsh'
127137
shell: pwsh
128138
env:
129139
ETHERPAD_DIR: ${{ runner.temp }}\etherpad-installer-test
@@ -132,6 +142,16 @@ jobs:
132142
NO_COLOR: "1"
133143
run: ./bin/installer.ps1
134144

145+
- name: Run bin/installer.ps1 against this commit (Windows PowerShell 5.1)
146+
if: matrix.shell == 'powershell'
147+
shell: powershell
148+
env:
149+
ETHERPAD_DIR: ${{ runner.temp }}\etherpad-installer-test
150+
ETHERPAD_REPO: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.clone_url || format('{0}/{1}.git', github.server_url, github.repository) }}
151+
ETHERPAD_BRANCH: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.ref || github.ref_name }}
152+
NO_COLOR: "1"
153+
run: ./bin/installer.ps1
154+
135155
- name: Verify clone + dependencies + build artifacts
136156
shell: pwsh
137157
env:

‎CHANGELOG.md‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,28 @@
1+
# 3.3.7
2+
3+
### Notable fixes
4+
5+
- **Admin — the plugin catalog no longer offers deprecated or known-broken plugins (#8246).** The "Available plugins" list was built straight from the plugin feed, so any package the feed knew about could be installed from the admin UI — including packages npm marks deprecated, packages the plugin registry itself could not get working against the current release, and `ep_adminpads2`, which is archived upstream and takes over `/admin/pads` with a template whose scripts core no longer ships, hanging the admin page on "Loading…". Those are now filtered out of the catalog, the admin UI refuses to install one if a stale page asks for it anyway (`pnpm run plugins i ep_<name>` on the server still overrides), and an already-installed plugin in that state is flagged as deprecated in the *Installed plugins* list. The npm deprecation lookup is cached for 12 hours and fails open: if the registry cannot be reached the full catalog is still listed. Reported by @JohnMcLear.
6+
7+
# 3.3.6
8+
9+
3.3.6 is a security and bug-fix release. It closes an OIDC login bypass for accounts configured without a password (GHSA-62cj-9j72-mfrh), and fixes a batch of reported defects across the installer, the admin settings editor, session transfer, the welcome screen, accessibility and plugin configuration.
10+
11+
### Security
12+
13+
- **OIDC — refuse interactive logins for accounts without a password (GHSA-62cj-9j72-mfrh, #8247).** The embedded OpenID Connect provider compared the submitted password against `String(user.password)`, so a `settings.users` account with no `password` property compared against the literal string `"undefined"`, and one with `"password": null` against `"null"`. Submitting that literal logged the account in and issued a token carrying its `admin` claim; with the default `authenticationMethod: "sso"` the HTTP API accepts that token. Accounts without a usable password occur in practice — the container image leaves `password` null when `ADMIN_PASSWORD` is unset, and an `ep_hash_auth` entry replaces `password` with `hash`. The sibling HTTP Basic path in `webaccess.ts` already failed closed here; the credential check now lives in `verifyInteractiveLogin()` and refuses any account without a real string password rather than coercing a missing secret to a literal. Hash-only entries are refused on this path too, since they authenticate through the `authenticate` hook, which it does not consult. Reported by Wenhao Wu (Southeast University).
14+
15+
### Notable fixes
16+
17+
- **PDF export honours `font-family` without LibreOffice (#8245, #8249).** The built-in PDF path used only pdfkit's Helvetica and Courier and ignored `font-family` entirely, so any font applied by a plugin such as `ep_font_family` was lost — while HTML, ODT and DOCX all carried it. Font families are now mapped onto the PDF standard fonts by category (sans-serif to Helvetica, serif to Times, monospace to Courier), including the bold and italic variants, honouring declaration order and `!important`. Exact non-standard faces can be supplied by an operator through the new `exportPdfFonts` setting, which points a family at TTF/OTF files; no fonts are bundled. Every failure path degrades with a warning rather than failing the export, and pads with no font styling export exactly as before. Family names arrive from pad content, so they are normalised and matched against an allow-list and are never used as a file path.
18+
- **Installer — the Node version check no longer fails under Windows PowerShell 5.1 (#8214, #8235).** `bin/installer.ps1` read the major version with `node -p 'process.versions.node.split(".")[0]'`. Windows PowerShell 5.1 — the default shell on Windows 10 and 11, and a version the script declares support for — strips the double quotes when passing arguments to a program, so Node received `split(.)[0]` and threw a `SyntaxError`. The empty result became 0, and the installer rejected every Node version as too old. The version now comes from `node --version` parsed in PowerShell, and an unparsable result reports that rather than claiming the version is too old. The Windows CI job now runs under both PowerShell 7 and Windows PowerShell 5.1.
19+
- **Admin — settings form fields honour escape sequences (#8211, #8239).** In the settings form view (raw mode was unaffected), string settings are edited in single-line inputs. Plain strings such as `defaultPadText` were rendered with literal newlines, which the browser silently strips from a single-line input, and whatever the user typed was escaped a second time on save, so `\n` was written as `\\n`. Environment-variable defaults such as `${DEFAULT_PAD_TEXT:...}` were shown escaped but escaped again on save. Both widgets now display values in escaped form and decode them before saving, so typing `Welcome\n\ntest\n` writes the same bytes as editing `settings.json` by hand. A half-typed escape is not saved: the field is marked invalid and reverts to the last saved value on blur.
20+
- **Session transfer — preferences survive the transfer, and the cookie is no longer double-encoded (#8171, #8238).** The transfer only handled the `prefsHttp` cookie, but over HTTPS the pad stores its preferences in `prefs`, so nothing was sent and the receiving side wrote a cookie the destination never reads. The client also sent the cookie still percent-encoded and `res.cookie()` encoded it again, leaving a value the destination pad could not parse, so it silently fell back to defaults. The server now reads the preferences from the request's own cookies (accepting either name, with or without the cookie prefix), accepts only a JSON object, and writes `prefs` or `prefsHttp` according to `req.secure`, encoded once. When there is nothing to transfer no cookie is written, so an existing destination preference set is no longer wiped. Author-token handling is unchanged.
21+
- **Session transfer — the dialog describes what actually happens (#8173, #8236).** The home-page dialog offered to copy a "link" that would move your "session". It copies a one-time code, valid once and for five minutes, that is pasted into the Receive session tab, and what moves is the author identity and preferences, not a sign-in session. The English wording of the existing strings now says so.
22+
- **Welcome screen — deleted pads leave the recent list (#8201, #8237).** The Recent pads list is stored in the browser and nothing ever removed an entry, so a deleted pad stayed listed and opening it silently created a new, empty pad under the same name. Clients now drop the pad from the list when the server announces the deletion, which covers the creator's own Delete pad action, a deletion performed with the recovery token from another device, and any other tab open on the pad. Names stored URL-encoded by older versions are matched too. Pads deleted through the HTTP API or the admin interface still linger in browsers that had no tab open on them, since the list is per-browser.
23+
- **Accessibility — screen readers can move through a pad line by line (#7778, #8240).** Every pad line is rendered as a plain `<div>` with no role, which browsers expose as an anonymous generic node, so assistive technology saw one flattened run of text with no line boundaries and no way to step between lines or reach the links on a line. Plain lines now carry `role="paragraph"`; lines that already contain a semantic block element, such as list items and headings from `ep_headings2`, keep their native semantics. The element itself is unchanged, so plugin selectors that target `div.ace-line` are unaffected.
24+
- **Plugins — `settings.ep_<plugin>` config blocks are reachable again from `require()` (#8109, #8110).** Plugins read their own configuration out of a top-level `ep_*` block in `settings.json` via `require('ep_etherpad-lite/node/utils/Settings')`. The CJS-compatibility shim in `Settings.ts` installed accessor properties on `module.exports` for the keys present on the settings object *while that module was still evaluating* — but `ep_*` blocks are only merged in later, by the `reloadSettings()` call at the bottom of the same module. Every plugin config block was therefore invisible to the `require()` path (the value was reachable only under `.default`), so plugins silently fell back to their built-in defaults. For `ep_hash_auth` that meant `hash_dir` reverted to `/var/etherpad/users`, every hash lookup failed, and admin login returned 401 with no usable diagnostic — the symptom that surfaced this. The shim is now re-run after each settings load. Reported by @mathewcsims and @tris-ots; an equivalent fix was also proposed by @AkprasadoP in #8113.
25+
126
# 3.3.5
227

328
3.3.5 is a bug-fix follow-up to 3.3.4. It fixes a startup crash on fresh installs when pnpm 12 (now pnpm's default release) is installed, and makes the built-in updater work on Windows.
@@ -17,6 +42,8 @@
1742

1843
### Notable fixes
1944

45+
- **Export — the office-export sanitizer now covers every URL carrier, not just `<img src>` (GHSA-x4mj-5635-3fq9).** The DOCX/PDF/ODT/DOC converters run server-side and dereference subresource URLs, so a plugin that splices pad content into export HTML could make the server issue outbound requests. The old filter tested one attribute on one tag with an anchored scheme regex, so a single leading space (` http://...`) made an absolute URL read as local, and `srcset`, `poster`, `<link href>`, `style="background:url(...)"`, `<object>`, `<iframe>` and friends were never inspected at all. Sanitization is now default-deny on the attribute *value*: anything canonicalizing to an absolute URL is dropped wherever it appears, entity- and percent-encodings are decoded before the check, `<base>`/`<script>`/`<iframe>`/`<object>`/`<embed>` are removed outright, and relative paths containing `..` are rejected so the converters cannot be pointed at unrelated files on disk. Relative URLs, `data:` URIs and ordinary `<a href>` hyperlinks are untouched, so exports render as before. Reported by Yazan Balawneh (Cystack.ps).
46+
2047
- **API — `movePad` now carries the pad's deletion token to the new id (#7995).** `movePad` is implemented as `copy()` + `remove()`, but `Pad.copy()` only copies the `pad:<id>`, `:revs:N` and `:chat:N` records — never `pad:<id>:deletionToken` — and `remove()` then deleted the source pad's token. The renamed pad therefore had no token at all: the token the creator had been told to save no longer deleted anything, and because the copy keeps the same revision-0 author, their next visit tripped `createDeletionTokenIfAbsent()` and popped a second "save your pad deletion token" modal. The token record is now handed over to the destination as part of the move, so the saved token keeps working and the modal does not reappear. `force`-overwriting an existing destination discards that pad's own token along with its content. `copyPad` is deliberately unchanged — two pads sharing one secret would let a token saved for one delete the other.
2148

2249
# 3.3.3

‎PRIVACY.md‎

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,9 @@ scope — audit any plugin you install.
1010

1111
Etherpad ships with two outbound calls to `etherpad.org`. Both are
1212
documented below. Both can be disabled with a single config value each.
13-
No analytics, no usage pings, no third-party SDKs at runtime.
13+
It also queries the public npm registry, but only while an admin is using
14+
the plugin manager — never on a plain pad server. No analytics, no usage
15+
pings, no third-party SDKs at runtime.
1416

1517
## Outbound calls
1618

@@ -36,6 +38,20 @@ No analytics, no usage pings, no third-party SDKs at runtime.
3638
| Disable | set `privacy.pluginCatalog: false` in `settings.json` (manual install via CLI still works) |
3739
| Source | `src/static/js/pluginfw/installer.ts` |
3840

41+
### 3. Plugin deprecation and engine check
42+
43+
| | |
44+
|---|---|
45+
| URL | `https://registry.npmjs.org/<plugin>/<version>` |
46+
| Frequency | once per listed plugin when an admin opens the plugin manager (cached 12 h), and once per plugin install |
47+
| Payload | GET only; same `User-Agent`; only `ep_*` package names are sent |
48+
| Purpose | hide plugins npm marks deprecated from the catalog, and refuse to install one that is deprecated or whose `engines.node` excludes the running Node |
49+
| Disable | set `privacy.pluginCatalog: false` in `settings.json` — the catalog, and with it the deprecation sweep, is then not used at all. The install-time check only runs when you install a plugin from the admin UI; `pnpm run plugins i` does not make this call |
50+
| Source | `src/static/js/pluginfw/installer.ts` |
51+
52+
Failures here are non-fatal by design: if npm is unreachable the catalog is
53+
still listed in full and installs still proceed.
54+
3955
## What we removed
4056

4157
`swagger-ui-express` was dropped because the upstream npm package

‎admin/package.json‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "admin",
33
"private": true,
4-
"version": "3.3.5",
4+
"version": "3.3.6",
55
"type": "module",
66
"scripts": {
77
"dev": "pnpm gen:api && vite",
@@ -36,7 +36,7 @@
3636
"eslint-plugin-react-refresh": "^0.5.7",
3737
"i18next": "^26.4.2",
3838
"i18next-browser-languagedetector": "^8.2.1",
39-
"lucide-react": "^1.45.0",
39+
"lucide-react": "^1.46.0",
4040
"react": "^19.3.0",
4141
"react-dom": "^19.3.0",
4242
"react-hook-form": "^7.88.0",
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { test } from 'node:test';
2+
import assert from 'node:assert/strict';
3+
import { applyEdits, modify, parse } from 'jsonc-parser';
4+
5+
import { escapeForInput, unescapeFromInput } from '../stringEscapes.ts';
6+
7+
// Regression for https://github.com/ether/etherpad/issues/8211.
8+
9+
test('newlines and backslashes are shown as JSON escapes', () => {
10+
assert.equal(escapeForInput('Welcome\n\ntest\n'), 'Welcome\\n\\ntest\\n');
11+
assert.equal(escapeForInput('C:\\dir\tx\r'), 'C:\\\\dir\\tx\\r');
12+
assert.equal(escapeForInput('\u0001'), '\\u0001');
13+
});
14+
15+
test('quotes and slashes stay readable', () => {
16+
assert.equal(escapeForInput('say "hi" https://etherpad.org'), 'say "hi" https://etherpad.org');
17+
});
18+
19+
test('typed escape sequences decode to the characters they name', () => {
20+
assert.equal(unescapeFromInput('Welcome\\n\\ntest\\n'), 'Welcome\n\ntest\n');
21+
assert.equal(unescapeFromInput('a\\"b\\/c\\\\d\\te\\u00e9'), 'a"b/c\\d\te\u00e9');
22+
assert.equal(unescapeFromInput('plain "quoted" text'), 'plain "quoted" text');
23+
});
24+
25+
test('invalid or incomplete escapes are rejected', () => {
26+
assert.equal(unescapeFromInput('trailing\\'), null);
27+
assert.equal(unescapeFromInput('bad \\q escape'), null);
28+
assert.equal(unescapeFromInput('short \\u12'), null);
29+
});
30+
31+
test('round-trips arbitrary strings', () => {
32+
for (const s of ['', 'x', 'Welcome to Etherpad!\n\nGet involved\n', 'a\\n', '"\\"', '\u2028\u0000']) {
33+
assert.equal(unescapeFromInput(escapeForInput(s)), s);
34+
}
35+
});
36+
37+
test('typed \\n is written to settings JSON as \\n, not \\\\n', () => {
38+
const text = '{\n "defaultPadText": "old"\n}';
39+
const decoded = unescapeFromInput('Welcome\\n\\ntest\\n');
40+
const next = applyEdits(text, modify(text, ['defaultPadText'], decoded, {
41+
formattingOptions: { tabSize: 2, insertSpaces: true, eol: '\n' },
42+
}));
43+
assert.ok(next.includes('"Welcome\\n\\ntest\\n"'), next);
44+
assert.equal(parse(next).defaultPadText, 'Welcome\n\ntest\n');
45+
});
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
// admin/src/components/settings/stringEscapes.ts
2+
//
3+
// Form-view string widgets are single-line <input>s. Browsers strip line
4+
// breaks from an <input>'s value, and settings.json documents values such
5+
// as `"defaultPadText": "Line 1\nLine 2"` using JSON escape sequences. So
6+
// the widgets show and accept string values in that same escaped form:
7+
// a newline is displayed as `\n`, and typing `\n` stores a newline
8+
// (issue #8211). Double quotes and slashes are left as-is for readability;
9+
// their escaped forms (`\"`, `\/`) are still accepted on input.
10+
11+
const SHORT_ESCAPES: Record<string, string> = {
12+
'\\': '\\\\',
13+
'\n': '\\n',
14+
'\r': '\\r',
15+
'\t': '\\t',
16+
'\b': '\\b',
17+
'\f': '\\f',
18+
};
19+
20+
const SHORT_UNESCAPES: Record<string, string> = {
21+
'"': '"',
22+
'\\': '\\',
23+
'/': '/',
24+
b: '\b',
25+
f: '\f',
26+
n: '\n',
27+
r: '\r',
28+
t: '\t',
29+
};
30+
31+
/** Turn a decoded string value into the escaped text shown in an input. */
32+
export const escapeForInput = (value: string): string =>
33+
// eslint-disable-next-line no-control-regex
34+
value.replace(/[\\\u0000-\u001f\u2028\u2029]/g, (c) =>
35+
SHORT_ESCAPES[c] ?? `\\u${c.charCodeAt(0).toString(16).padStart(4, '0')}`);
36+
37+
/**
38+
* Decode the escaped text typed into an input back into the string value.
39+
* Returns null when the text contains an invalid or incomplete escape
40+
* sequence (e.g. a trailing `\` while the user is still typing).
41+
*/
42+
export const unescapeFromInput = (text: string): string | null => {
43+
let out = '';
44+
for (let i = 0; i < text.length; i++) {
45+
const c = text[i];
46+
if (c !== '\\') {
47+
out += c;
48+
continue;
49+
}
50+
const next = text[i + 1];
51+
if (next === undefined) return null;
52+
if (next === 'u') {
53+
const hex = text.slice(i + 2, i + 6);
54+
if (!/^[0-9a-fA-F]{4}$/.test(hex)) return null;
55+
out += String.fromCharCode(parseInt(hex, 16));
56+
i += 5;
57+
continue;
58+
}
59+
const decoded = SHORT_UNESCAPES[next];
60+
if (decoded === undefined) return null;
61+
out += decoded;
62+
i += 1;
63+
}
64+
return out;
65+
};

0 commit comments

Comments
 (0)