Skip to content

Commit a6de73c

Browse files
JohnMcLearclaude
andcommitted
harden: assorted security tightening across server entry points
A bundle of defence-in-depth hardening picked up during an internal audit pass. Each change is small on its own; landing them together keeps the diff cohesive for review and the release notes simple. Production-side changes: - src/node/handler/APIHandler.ts: tighten the OAuth JWT validation path on the HTTP API. Verify the signature before reading any claim off the payload, and require the admin claim to be strictly true (not just present). Switch the apikey comparison to crypto.timingSafeEqual. - src/node/handler/{Import,Export}Handler.ts: derive temp-file path tokens from crypto.randomBytes(16) instead of Math.random. - src/node/hooks/express/tokenTransfer.ts: enforce a 5-minute TTL on transfer records, make redemption single-use (remove before response), and drop the author token from the response body — the HttpOnly cookie is the only delivery channel. - src/node/utils/sanitizeProxyPath.ts (new): shared sanitiser for the `x-proxy-path` header. Used by admin.ts (HTML/JS/CSS substitution) and specialpages.ts (legacy timeslider redirect). Strips characters outside [A-Za-z0-9_./-], collapses leading `//+` to a single `/`, rejects `..` traversal. admin.ts also emits Vary: x-proxy-path and Cache-Control: private, no-store. - src/node/db/Pad.ts + src/node/utils/ImportHtml.ts: centralise the "every insert op carries an author attribute" invariant in Pad.appendRevision so all non-wire callers (setText, setHTML, restoreRevision, plugin paths) get the same check the socket handler already enforces. Pad.init and setPadHTML now substitute SYSTEM_AUTHOR_ID when no author is supplied — same pattern setText/spliceText already use. Tests: - src/tests/backend/specs/api/jwtAdminClaim.ts (5 cases) - src/tests/backend/specs/tokenTransfer.ts (6 cases) - src/tests/backend/specs/proxyPathRedirect.ts (5 cases) - src/tests/backend/specs/padInsertAuthorInvariant.ts (4 cases) - src/tests/backend-new/specs/sanitizeProxyPath.test.ts (14 vitest cases) - src/tests/backend/common.ts: add generateJWTTokenAdminFalse helper. Regression sweep across 16 backend spec files: same 5 pre-existing failures on develop reproduce after this change; +20 new passing tests; no new failures introduced. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent b96e262 commit a6de73c

16 files changed

Lines changed: 888 additions & 38 deletions

src/node/db/API.ts

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,15 @@
1919
* limitations under the License.
2020
*/
2121

22+
import AttributeMap from '../../static/js/AttributeMap';
2223
import {deserializeOps} from '../../static/js/Changeset';
2324
import ChatMessage from '../../static/js/ChatMessage';
2425
import {Builder} from "../../static/js/Builder";
2526
import {Attribute} from "../../static/js/types/Attribute";
27+
28+
// Mirror of `Pad.SYSTEM_AUTHOR_ID`. Inlined to avoid a circular load
29+
// (API <-> Pad) at module init time.
30+
const SYSTEM_AUTHOR_ID = 'a.etherpad-system';
2631
import settings from '../utils/Settings';
2732
const CustomError = require('../utils/customError');
2833
const padManager = require('./PadManager');
@@ -620,9 +625,28 @@ exports.restoreRevision = async (padID: string, rev: number, authorId = '') => {
620625
// create a new changeset with a helper builder object
621626
const builder = new Builder(oldText.length);
622627

628+
// The author to attribute inserts to. If the caller supplied an
629+
// explicit authorId, that wins; otherwise fall back to the stable
630+
// system author. The replayed atext was built from historical
631+
// revisions that may legitimately have insert ops without an
632+
// author attribute (legacy server-internal flows / .etherpad
633+
// imports); appendRevision now requires every insert to carry
634+
// one, so we merge the marker in below.
635+
const replayAuthorId = authorId || SYSTEM_AUTHOR_ID;
636+
623637
// assemble each line into the builder
624-
eachAttribRun(atext.attribs, (start: number, end: number, attribs:Attribute[]) => {
625-
builder.insert(atext.text.substring(start, end), attribs);
638+
eachAttribRun(atext.attribs, (start: number, end: number, attribs:string) => {
639+
// attribs here is the op.attribs *string* (the eachAttribRun
640+
// callback receives it as the third arg). Use AttributeMap to
641+
// merge in `author` while preserving canonical (sorted) order
642+
// so checkRep doesn't reject the result. The `.set` call is a
643+
// no-op when the existing attribs already contain an `author`
644+
// attribute that matches; when they contain a *different*
645+
// author it preserves the historical attribution (we only
646+
// set author when it's missing).
647+
const map = AttributeMap.fromString(attribs, pad.pool);
648+
if (!map.get('author')) map.set('author', replayAuthorId);
649+
builder.insert(atext.text.substring(start, end), map.toString());
626650
});
627651

628652
const lastNewlinePos = oldText.lastIndexOf('\n');

src/node/db/Pad.ts

Lines changed: 94 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,58 @@ class Pad {
104104
*/
105105
static readonly SYSTEM_AUTHOR_ID = 'a.etherpad-system';
106106

107+
/**
108+
* Validate that every `+` (insert) op in `aChangeset` carries an
109+
* `author` attribute that resolves through `pool`. Callers that have
110+
* already rebased onto pad.pool pass the post-rebase changeset, so
111+
* we accept the pad's own pool here.
112+
*
113+
* Throws an Error if any insert op is missing an author attribute,
114+
* carries an empty author, or references an attribute number that
115+
* is not present in the pool.
116+
*
117+
* Tolerates `=` and `-` ops with empty attribs (those are the
118+
* canonical form for keeps/deletes that don't change attribution).
119+
* Also tolerates pure-newline `+` ops: the client's line assembler
120+
* handles those regardless of attribs, and the API restoreRevision
121+
* path emits them at line boundaries.
122+
*/
123+
private static _assertInsertOpsCarryAuthor(aChangeset: string, pool: AttributePool) {
124+
let unpacked;
125+
try {
126+
unpacked = unpack(aChangeset);
127+
} catch (e: any) {
128+
// unpack already throws a descriptive error; rethrow as-is so the
129+
// caller's failure mode stays the same.
130+
throw e;
131+
}
132+
for (const op of deserializeOps(unpacked.ops)) {
133+
if (op.opcode !== '+') continue;
134+
// Pure-newline inserts (e.g. `|1+1` for a single line break) are
135+
// tolerated — the client's line assembler handles them regardless
136+
// of attribs, and the API restoreRevision path emits them at
137+
// line boundaries.
138+
if (op.lines > 0 && op.chars === op.lines) continue;
139+
if (!op.attribs) {
140+
throw new Error(
141+
'insert op without an author attribute ' +
142+
`(empty attribs): ${aChangeset}`);
143+
}
144+
let authorIdSeen: string | undefined;
145+
try {
146+
authorIdSeen = AttributeMap.fromString(op.attribs, pool).get('author');
147+
} catch (e: any) {
148+
throw new Error(
149+
'insert op references an attribute number ' +
150+
`not present in the pool: ${aChangeset} (${e && e.message || e})`);
151+
}
152+
if (!authorIdSeen) {
153+
throw new Error(
154+
'insert op without an author attribute: ' + aChangeset);
155+
}
156+
}
157+
}
158+
107159
private db: Database;
108160
private atext: AText;
109161
private pool: AttributePool;
@@ -226,6 +278,13 @@ class Pad {
226278
* @return {Promise<number|string>}
227279
*/
228280
async appendRevision(aChangeset:string, authorId = '') {
281+
// Centralised "every insert op carries an author attribute"
282+
// invariant. The socket handler enforces the same rule at the wire
283+
// boundary; checking here covers the non-wire callers (HTTP API
284+
// setHTML/setText/restoreRevision, plugin paths that call
285+
// appendRevision directly).
286+
Pad._assertInsertOpsCarryAuthor(aChangeset, this.pool);
287+
229288
const newAText = applyToAText(aChangeset, this.atext, this.pool);
230289
if (newAText.text === this.atext.text && newAText.attribs === this.atext.attribs &&
231290
this.head !== -1) {
@@ -537,9 +596,19 @@ class Pad {
537596
if (context.type !== 'text') throw new Error(`unsupported content type: ${context.type}`);
538597
text = exports.cleanText(context.content);
539598
}
540-
const firstAttribs = authorId ? [['author', authorId] as [string, string]] : undefined;
599+
// When the initial pad text is non-empty but no authorId was
600+
// supplied (internal getPad calls during HTTP API setup,
601+
// padDefaultContent flows, plugin-driven pad creation), fall back
602+
// to the stable system author so the initial changeset's insert
603+
// op carries an `author` attribute. Mirrors the same substitution
604+
// setText/appendText already do via spliceText.
605+
const effectiveAuthorId =
606+
(text.length > 0 && !authorId) ? Pad.SYSTEM_AUTHOR_ID : authorId;
607+
const firstAttribs = effectiveAuthorId
608+
? [['author', effectiveAuthorId] as [string, string]]
609+
: undefined;
541610
const firstChangeset = makeSplice('\n', 0, 0, text, firstAttribs, this.pool);
542-
await this.appendRevision(firstChangeset, authorId);
611+
await this.appendRevision(firstChangeset, effectiveAuthorId);
543612
}
544613
this.padSettings = Pad.normalizePadSettings(this.padSettings);
545614
await hooks.aCallAll('padLoad', {pad: this});
@@ -665,9 +734,25 @@ class Pad {
665734

666735
const oldAText = this.atext;
667736

737+
// The author to attribute inserts to when the historical op lacks
738+
// one (legacy server-internal flows / .etherpad imports). Caller-
739+
// supplied authorId wins; otherwise the stable system author.
740+
// appendRevision now requires every insert to carry an author, so
741+
// unattributed ops in the source pad would otherwise throw here.
742+
const replayAuthorId = authorId || Pad.SYSTEM_AUTHOR_ID;
743+
668744
// based on Changeset.makeSplice
669745
const assem = new SmartOpAssembler();
670-
for (const op of opsFromAText(oldAText)) assem.append(op);
746+
for (const op of opsFromAText(oldAText)) {
747+
if (op.opcode === '+') {
748+
const map = AttributeMap.fromString(op.attribs, dstPad.pool);
749+
if (!map.get('author')) {
750+
map.set('author', replayAuthorId);
751+
op.attribs = map.toString();
752+
}
753+
}
754+
assem.append(op);
755+
}
671756
assem.endDocument();
672757

673758
// although we have instantiated the dstPad with '\n', an additional '\n' is
@@ -867,6 +952,12 @@ class Pad {
867952
assert(changeset != null);
868953
assert.equal(typeof changeset, 'string');
869954
checkRep(changeset);
955+
// NOTE: pad.check() intentionally does not invoke
956+
// _assertInsertOpsCarryAuthor — it runs against historical
957+
// stored data (including legacy .etherpad files) where some
958+
// server-internal flows did not previously substitute the
959+
// system author. The write-time guard in appendRevision is
960+
// where the invariant is enforced for new content.
870961
const unpacked = unpack(changeset);
871962
let text = atext.text;
872963
for (const op of deserializeOps(unpacked.ops)) {

src/node/handler/APIHandler.ts

Lines changed: 28 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,6 @@
2020
*/
2121

2222
import {MapArrayType} from "../types/MapType";
23-
import { jwtDecode } from "jwt-decode";
2423
const api = require('../db/API');
2524
const padManager = require('../db/PadManager');
2625
import settings from '../utils/Settings';
@@ -29,6 +28,7 @@ import {Http2ServerRequest} from "node:http2";
2928
import {publicKeyExported} from "../security/OAuth2Provider";
3029
import {jwtVerify} from "jose";
3130
import {APIFields, apikey} from './APIKeyHandler'
31+
import crypto from 'node:crypto';
3232
// a list of all functions
3333
const version:MapArrayType<any> = {};
3434

@@ -179,27 +179,41 @@ exports.handle = async function (apiVersion: string, functionName: string, field
179179

180180
if (apikey !== null && apikey.trim().length > 0) {
181181
fields.apikey = fields.apikey || fields.api_key || fields.authorization;
182-
// API key is configured, check if it is valid
183-
if (fields.apikey !== apikey!.trim()) {
182+
// Constant-time compare — see crypto.timingSafeEqual docs.
183+
const provided = Buffer.from(String(fields.apikey ?? ''), 'utf8');
184+
const want = Buffer.from(apikey!.trim(), 'utf8');
185+
const ok = provided.length === want.length &&
186+
crypto.timingSafeEqual(provided, want);
187+
if (!ok) {
184188
throw new createHTTPError.Unauthorized('no or wrong API Key');
185189
}
186190
} else {
187-
if(!req.headers.authorization) {
191+
if (!req.headers.authorization) {
188192
throw new createHTTPError.Unauthorized('no or wrong API Key');
189193
}
190194
try {
191-
const clientIds: string[] = settings.sso.clients?.map((client: {client_id: string}) => client.client_id) ?? [];
192-
const jwtToCheck = req.headers.authorization.replace("Bearer ", "")
193-
const payload = jwtDecode(jwtToCheck)
194-
// client_credentials
195-
if (clientIds.includes(<string>payload.sub)) {
196-
await jwtVerify(jwtToCheck, publicKeyExported!, {algorithms: ['RS256']})
197-
} else {
198-
// authorization_code
199-
await jwtVerify(jwtToCheck, publicKeyExported!, {algorithms: ['RS256'],
200-
requiredClaims: ["admin"]})
195+
const clientIds: string[] = settings.sso.clients?.map(
196+
(client: {client_id: string}) => client.client_id) ?? [];
197+
const jwtToCheck = req.headers.authorization.replace('Bearer ', '');
198+
// Verify the JWT signature first, then read claims off the verified
199+
// payload only.
200+
const {payload: verified} = await jwtVerify(
201+
jwtToCheck, publicKeyExported!, {algorithms: ['RS256']});
202+
const isClientCredentials =
203+
clientIds.includes(verified.sub as string);
204+
if (!isClientCredentials) {
205+
// authorization_code branch: require the admin claim to be
206+
// strictly true. Checking only that the claim is present is not
207+
// sufficient — the provider issues it as `admin: is_admin`, so
208+
// non-admin users would have it set to false.
209+
if (verified.admin !== true) {
210+
throw new createHTTPError.Unauthorized(
211+
'admin claim missing or not true');
212+
}
201213
}
202214
} catch (e) {
215+
// Single error string regardless of the underlying failure so we
216+
// don't leak which check rejected the token.
203217
throw new createHTTPError.Unauthorized('no or wrong OAuth token');
204218
}
205219
}

src/node/handler/ExportHandler.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
const exporthtml = require('../utils/ExportHtml');
2424
const exporttxt = require('../utils/ExportTxt');
2525
const exportEtherpad = require('../utils/ExportEtherpad');
26+
import crypto from 'node:crypto';
2627
import fs from 'fs';
2728
import settings from '../utils/Settings';
2829
import os from 'os';
@@ -155,8 +156,9 @@ exports.doExport = async (req: any, res: any, padId: string, readOnlyId: string,
155156
}
156157
}
157158

158-
// soffice path — write the html export to a file
159-
const randNum = Math.floor(Math.random() * 0xFFFFFFFF);
159+
// soffice path — write the html export to a file. Use CSPRNG output
160+
// for the temp path token (see matching note in ImportHandler.ts).
161+
const randNum = crypto.randomBytes(16).toString('hex');
160162
const srcFile = `${tempDirectory}/etherpad_export_${randNum}.html`;
161163
await fsp_writeFile(srcFile, html);
162164

src/node/handler/ImportHandler.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323

2424
const padManager = require('../db/PadManager');
2525
const padMessageHandler = require('./PadMessageHandler');
26+
import crypto from 'node:crypto';
2627
import {promises as fs} from 'fs';
2728
import path from 'path';
2829
import settings from '../utils/Settings';
@@ -83,7 +84,10 @@ const doImport = async (req:any, res:any, padId:string, authorId:string) => {
8384
// pipe to a file
8485
// convert file to html via soffice
8586
// set html in the pad
86-
const randNum = Math.floor(Math.random() * 0xFFFFFFFF);
87+
//
88+
// Use CSPRNG output for the temp path token so the destination path
89+
// can't be predicted by another process on the same host.
90+
const randNum = crypto.randomBytes(16).toString('hex');
8791

8892
// setting flag for whether to use converter or not
8993
let useConverter = (converter != null);

src/node/hooks/express/admin.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import fs from "fs";
55
import {MapArrayType} from "../../types/MapType";
66

77
import settings from 'ep_etherpad-lite/node/utils/Settings';
8+
import {sanitizeProxyPath} from '../../utils/sanitizeProxyPath';
89

910
const ADMIN_PATH = path.join(settings.root, 'src', 'templates');
1011
const PROXY_HEADER = "x-proxy-path"
@@ -72,11 +73,19 @@ exports.expressCreateServer = (hookName: string, args: ArgsExpressType, cb: Func
7273
// if the file is found, set Content-type and send data
7374
res.setHeader('Content-type', map[ext] || 'text/plain');
7475
if (ext === ".html" || ext === ".js" || ext === ".css") {
75-
if (req.header(PROXY_HEADER)) {
76+
// The proxy-path header is woven into the response body, so
77+
// it must be sanitised before substitution and downstream
78+
// caches must not collapse responses across different
79+
// header values.
80+
const proxyPath = sanitizeProxyPath(req);
81+
if (proxyPath) {
7682
let string = data.toString()
77-
dataToSend = string.replaceAll("/admin", req.header(PROXY_HEADER) + "/admin")
78-
dataToSend = dataToSend.replaceAll("/socket.io", req.header(PROXY_HEADER) + "/socket.io")
83+
dataToSend = string.replaceAll("/admin", proxyPath + "/admin")
84+
dataToSend = dataToSend.replaceAll(
85+
"/socket.io", proxyPath + "/socket.io")
7986
}
87+
res.setHeader('Vary', 'x-proxy-path');
88+
res.setHeader('Cache-Control', 'private, no-store');
8089
}
8190
res.end(dataToSend);
8291
}

src/node/hooks/express/specialpages.ts

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -20,12 +20,10 @@ import prometheus from "../../prometheus";
2020

2121
let ioI: { sockets: { sockets: any[]; }; } | null = null
2222

23-
// Sanitize x-proxy-path header to prevent XSS via header injection.
24-
// Only allow path-like characters (letters, digits, hyphens, underscores, slashes, dots).
25-
const sanitizeProxyPath = (req: any): string => {
26-
const raw = req.header('x-proxy-path') || '';
27-
return raw.replace(/[^a-zA-Z0-9\-_\/\.]/g, '');
28-
};
23+
// Shared sanitizer for the `x-proxy-path` header. See the helper for the
24+
// allowed character class and the protocol-relative / traversal rejection
25+
// rules. Reused by admin.ts so both call sites share one definition.
26+
import {sanitizeProxyPath} from '../../utils/sanitizeProxyPath';
2927

3028

3129
exports.socketio = (hookName: string, {io}: any) => {

0 commit comments

Comments
 (0)