Skip to content

Commit 714f66c

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 714f66c

15 files changed

Lines changed: 764 additions & 34 deletions

src/node/db/Pad.ts

Lines changed: 77 additions & 2 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});
@@ -867,6 +936,12 @@ class Pad {
867936
assert(changeset != null);
868937
assert.equal(typeof changeset, 'string');
869938
checkRep(changeset);
939+
// NOTE: pad.check() intentionally does not invoke
940+
// _assertInsertOpsCarryAuthor — it runs against historical
941+
// stored data (including legacy .etherpad files) where some
942+
// server-internal flows did not previously substitute the
943+
// system author. The write-time guard in appendRevision is
944+
// where the invariant is enforced for new content.
870945
const unpacked = unpack(changeset);
871946
let text = atext.text;
872947
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) => {

src/node/hooks/express/tokenTransfer.ts

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,17 @@ import settings from '../../utils/Settings';
77
type TokenTransferRequest = {
88
token: string;
99
prefsHttp: string,
10-
createdAt?: number;
10+
createdAt: number;
1111
}
1212

13-
const tokenTransferKey = "tokenTransfer:";
13+
// Keep the legacy on-the-wire key shape so any in-flight transfers
14+
// created before this change are still redeemable.
15+
const tokenTransferKey = (id: string) => `tokenTransfer::${id}`;
16+
17+
// Transfer records have a hard TTL — the legitimate flow is "scan a QR
18+
// code on another device and click within a few minutes". A stale id
19+
// should not be redeemable indefinitely.
20+
const TRANSFER_TTL_MS = 5 * 60 * 1000;
1421

1522
export const expressCreateServer = (hookName:string, {app}:ArgsExpressType) => {
1623
app.post('/tokenTransfer', async (req: any, res) => {
@@ -33,7 +40,7 @@ export const expressCreateServer = (hookName:string, {app}:ArgsExpressType) =>
3340
createdAt: Date.now(),
3441
};
3542

36-
await db.set(`${tokenTransferKey}:${id}`, token);
43+
await db.set(tokenTransferKey(id), token);
3744
res.send({id});
3845
})
3946

@@ -43,11 +50,26 @@ export const expressCreateServer = (hookName:string, {app}:ArgsExpressType) =>
4350
return res.status(400).send({error: 'Invalid request'});
4451
}
4552

46-
const tokenData = await db.get(`${tokenTransferKey}:${id}`);
53+
const key = tokenTransferKey(id);
54+
const tokenData: TokenTransferRequest | undefined = await db.get(key);
4755
if (!tokenData) {
4856
return res.status(404).send({error: 'Token not found'});
4957
}
5058

59+
// Single-use: remove the record BEFORE the response is sent, so a
60+
// parallel request that wins the race observes an already-redeemed
61+
// transfer rather than a second usable copy.
62+
await db.remove(key);
63+
64+
// Enforce the TTL. Absent/non-numeric createdAt is treated as
65+
// expired so legacy records that pre-date this code path are
66+
// rejected on the safe side.
67+
const createdAt = typeof tokenData.createdAt === 'number'
68+
? tokenData.createdAt : 0;
69+
if (Date.now() - createdAt > TRANSFER_TTL_MS) {
70+
return res.status(410).send({error: 'Token expired'});
71+
}
72+
5173
const p = settings.cookie.prefix;
5274
// Re-issue the author token on the new device as an HttpOnly cookie to
5375
// match the /p/:pad path (ether/etherpad#6701 PR3). Without this, the
@@ -63,6 +85,9 @@ export const expressCreateServer = (hookName:string, {app}:ArgsExpressType) =>
6385
res.cookie(`${p}prefsHttp`, tokenData.prefsHttp, {
6486
path: '/', maxAge: 1000 * 60 * 60 * 24 * 365,
6587
});
66-
res.send(tokenData);
88+
// Body must NOT echo the author token — the HttpOnly cookie above
89+
// is the only channel. Body advertises only the non-secret prefs
90+
// the client needs to wire up locally.
91+
res.send({ok: true, prefsHttp: tokenData.prefsHttp});
6792
})
6893
}

src/node/utils/ImportHtml.ts

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,17 @@
1616
*/
1717

1818
import log4js from 'log4js';
19+
import AttributeMap from '../../static/js/AttributeMap';
1920
import {deserializeOps} from '../../static/js/Changeset';
2021
const contentcollector = require('../../static/js/contentcollector');
2122
import jsdom from 'jsdom';
2223
import {PadType} from "../types/PadType";
2324
import {Builder} from "../../static/js/Builder";
2425

26+
// Mirror of `Pad.SYSTEM_AUTHOR_ID`. Imported as a literal to avoid a
27+
// circular require between Pad and ImportHtml during module init.
28+
const SYSTEM_AUTHOR_ID = 'a.etherpad-system';
29+
2530
const apiLogger = log4js.getLogger('ImportHtml');
2631
let processor:any;
2732

@@ -72,6 +77,15 @@ exports.setPadHTML = async (pad: PadType, html:string, authorId = '') => {
7277
// create a new changeset with a helper builder object
7378
const builder = new Builder(1);
7479

80+
// Every insert op needs an `author` attribute (the appendRevision
81+
// precondition). The contentcollector tags ops with style
82+
// attributes (bold, italic, etc.) but doesn't add an author; for
83+
// server-side imports the author is implicit in the caller, so
84+
// substitute the system author when no explicit one was supplied —
85+
// same pattern setText/spliceText already use.
86+
const effectiveAuthorId =
87+
(newText.length > 0 && !authorId) ? SYSTEM_AUTHOR_ID : authorId;
88+
7589
// assemble each line into the builder
7690
let textIndex = 0;
7791
const newTextStart = 0;
@@ -81,7 +95,16 @@ exports.setPadHTML = async (pad: PadType, html:string, authorId = '') => {
8195
if (!(nextIndex <= newTextStart || textIndex >= newTextEnd)) {
8296
const start = Math.max(newTextStart, textIndex);
8397
const end = Math.min(newTextEnd, nextIndex);
84-
builder.insert(newText.substring(start, end), op.attribs);
98+
// Merge via AttributeMap so the result is in canonical order
99+
// (sorted by pool index) — a raw `*N` prefix could violate
100+
// checkRep's canonical-form assertion.
101+
let mergedAttribs = op.attribs;
102+
if (effectiveAuthorId) {
103+
mergedAttribs = AttributeMap.fromString(op.attribs, pad.pool)
104+
.set('author', effectiveAuthorId)
105+
.toString();
106+
}
107+
builder.insert(newText.substring(start, end), mergedAttribs);
85108
}
86109
textIndex = nextIndex;
87110
}

0 commit comments

Comments
 (0)