Skip to content

Commit 4137109

Browse files
JohnMcLearclaude
andauthored
fix: allow undo of clear authorship colors without disconnect (#7430)
* fix: allow undo of clear authorship colors without disconnect (#2802) When a user clears authorship colors and then undoes, the undo changeset re-applies author attributes for all authors who contributed text. The server was rejecting this because it treated any changeset containing another author's ID as impersonation, disconnecting the user. The fix distinguishes between: - '+' ops (new text): still reject if attributed to another author - '=' ops (attribute changes on existing text): allow restoring other authors' attributes, which is needed for undo of clear authorship Also removes the client-side workaround in undomodule.ts that prevented clear authorship from being undone at all, and adds backend + frontend tests covering the multi-author undo scenario. Fixes: #2802 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: use robust Playwright assertions in authorship undo tests - Use toHaveAttribute with regex instead of raw getAttribute + toContain - Check div/span attributes within pad body instead of broad selectors - Use Playwright auto-retry (expect with timeout) instead of toHaveCount(0) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: handle confirm dialog and sync timing in Playwright tests - Add page.on('dialog') handler to accept the confirm dialog triggered by clearAuthorship when no text is selected (clears whole pad) - Use auto-retrying toHaveAttribute assertions instead of raw getAttribute - Increase cross-user sync timeouts to 15s for CI reliability - Add retries: 2 to multi-user test for CI flakiness - Scope assertions to pad body spans instead of broad selectors Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: use persistent socket listeners to avoid missing messages in CI Replace sequential waitForSocketEvent loops with single persistent listeners that filter messages inline. This prevents race conditions where messages arrive between off/on listener cycles, causing timeouts on slower CI runners. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: reject - ops with foreign author to prevent pool injection The '-' op attribs are discarded from the document but still get added to the pad's attribute pool by moveOpsToNewPool. Without this check, an attacker could inject a fabricated author ID into the pool via a '-' op, then use a '=' op to attribute text to that fabricated author (bypassing the pool existence check). Now all non-'=' ops (+, -) with foreign author IDs are rejected. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * test: use not.toHaveClass for cleared authorship spans Addresses Qodo review: linestylefilter skips attribs with empty values, so a span with author='' has no class attribute at all. The previous negative-lookahead regex on the class attribute failed against a null attribute and was flaky in CI. Switch to not.toHaveClass(/author-/), which also passes when the attribute is missing. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 84060fb commit 4137109

5 files changed

Lines changed: 516 additions & 24 deletions

File tree

src/node/handler/PadMessageHandler.ts

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -651,7 +651,12 @@ const handleUserChanges = async (socket:any, message: {
651651
// Verify that the changeset has valid syntax and is in canonical form
652652
checkRep(changeset);
653653

654-
// Validate all added 'author' attribs to be the same value as the current user
654+
// Validate all added 'author' attribs to be the same value as the current user.
655+
// Exception: '=' ops (attribute changes on existing text) are allowed to restore other authors'
656+
// IDs, but only if that author already exists in the pad's pool (i.e., they genuinely
657+
// contributed to this pad). This is necessary for undoing "clear authorship colors", which
658+
// re-applies the original author attributes for all authors.
659+
// See https://github.com/ether/etherpad-lite/issues/2802
655660
for (const op of deserializeOps(unpack(changeset).ops)) {
656661
// + can add text with attribs
657662
// = can change or add attribs
@@ -663,8 +668,24 @@ const handleUserChanges = async (socket:any, message: {
663668
// an attribute number isn't in the pool).
664669
const opAuthorId = AttributeMap.fromString(op.attribs, wireApool).get('author');
665670
if (opAuthorId && opAuthorId !== thisSession.author) {
666-
throw new Error(`Author ${thisSession.author} tried to submit changes as author ` +
667-
`${opAuthorId} in changeset ${changeset}`);
671+
if (op.opcode === '=') {
672+
// Allow restoring author attributes on existing text (undo of clear authorship),
673+
// but only if the author ID is already known to this pad. This prevents a user
674+
// from attributing text to a fabricated author who never contributed to the pad.
675+
const knownAuthor = pad.pool.putAttrib(['author', opAuthorId], true) !== -1;
676+
if (!knownAuthor) {
677+
throw new Error(`Author ${thisSession.author} tried to set unknown author ` +
678+
`${opAuthorId} on existing text in changeset ${changeset}`);
679+
}
680+
} else {
681+
// Reject '+' ops (inserting new text as another author) and '-' ops (deleting
682+
// with another author's attribs). While '-' attribs are discarded from the
683+
// document, they are added to the pad's attribute pool by moveOpsToNewPool,
684+
// which could be exploited to inject fabricated author IDs into the pool and
685+
// bypass the '=' op pool check above.
686+
throw new Error(`Author ${thisSession.author} tried to submit changes as author ` +
687+
`${opAuthorId} in changeset ${changeset}`);
688+
}
668689
}
669690
}
670691

src/static/js/undomodule.ts

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -212,13 +212,7 @@ const undoModule = (() => {
212212
}
213213
}
214214
if (!merged) {
215-
/*
216-
* Push the event on the undo stack only if it exists, and if it's
217-
* not a "clearauthorship". This disallows undoing the removal of the
218-
* authorship colors, but is a necessary stopgap measure against
219-
* https://github.com/ether/etherpad-lite/issues/2802
220-
*/
221-
if (event && (event.eventType !== 'clearauthorship')) {
215+
if (event) {
222216
stack.pushEvent(event);
223217
}
224218
}
Lines changed: 337 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,337 @@
1+
'use strict';
2+
3+
/**
4+
* Tests for https://github.com/ether/etherpad-lite/issues/2802
5+
*
6+
* When User B clears authorship colors (removing all author attributes) and then undoes,
7+
* the undo changeset re-applies author attributes for ALL authors (A and B). The server
8+
* rejects this because User B is submitting changes containing User A's author ID, causing
9+
* a disconnect with "badChangeset".
10+
*
11+
* The server should allow undo of clear authorship without disconnecting the user.
12+
*/
13+
14+
import {PadType} from "../../../node/types/PadType";
15+
16+
const assert = require('assert').strict;
17+
const common = require('../common');
18+
const padManager = require('../../../node/db/PadManager');
19+
import AttributePool from '../../../static/js/AttributePool';
20+
import padutils from '../../../static/js/pad_utils';
21+
22+
describe(__filename, function () {
23+
let agent: any;
24+
let pad: PadType | null;
25+
let padId: string;
26+
let socketA: any;
27+
let socketB: any;
28+
let revA: number;
29+
let revB: number;
30+
31+
before(async function () {
32+
agent = await common.init();
33+
});
34+
35+
beforeEach(async function () {
36+
padId = common.randomString();
37+
assert(!await padManager.doesPadExist(padId));
38+
pad = await padManager.getPad(padId, '');
39+
await pad!.setText('\n');
40+
assert.equal(pad!.text(), '\n');
41+
});
42+
43+
afterEach(async function () {
44+
if (socketA != null) socketA.close();
45+
socketA = null;
46+
if (socketB != null) socketB.close();
47+
socketB = null;
48+
if (pad != null) await pad.remove();
49+
pad = null;
50+
});
51+
52+
/**
53+
* Connect a user to the pad with a unique author token.
54+
*/
55+
const connectUser = async () => {
56+
const res = await agent.get(`/p/${padId}`).expect(200);
57+
const socket = await common.connect(res);
58+
const token = padutils.generateAuthorToken();
59+
const {type, data: clientVars} = await common.handshake(socket, padId, token);
60+
assert.equal(type, 'CLIENT_VARS');
61+
const rev = clientVars.collab_client_vars.rev;
62+
const author = clientVars.userId;
63+
return {socket, rev, author};
64+
};
65+
66+
const sendUserChanges = async (socket: any, baseRev: number, changeset: string, apool?: any) => {
67+
await common.sendUserChanges(socket, {
68+
baseRev,
69+
changeset,
70+
...(apool ? {apool} : {}),
71+
});
72+
};
73+
74+
/**
75+
* Wait for an ACCEPT_COMMIT or disconnect message, ignoring other messages.
76+
* Uses a single persistent listener to avoid missing messages between on/off cycles.
77+
*/
78+
const waitForCommitOrDisconnect = (socket: any, timeoutMs = 10000): Promise<any> => {
79+
return new Promise((resolve, reject) => {
80+
const timeout = setTimeout(() => {
81+
socket.off('message', handler);
82+
reject(new Error(`timed out waiting for ACCEPT_COMMIT or disconnect after ${timeoutMs}ms`));
83+
}, timeoutMs);
84+
const handler = (msg: any) => {
85+
if (msg.disconnect) {
86+
clearTimeout(timeout);
87+
socket.off('message', handler);
88+
resolve(msg);
89+
} else if (msg.type === 'COLLABROOM' && msg.data?.type === 'ACCEPT_COMMIT') {
90+
clearTimeout(timeout);
91+
socket.off('message', handler);
92+
resolve(msg);
93+
}
94+
// Ignore USER_NEWINFO, NEW_CHANGES, etc.
95+
};
96+
socket.on('message', handler);
97+
});
98+
};
99+
100+
const waitForAcceptCommit = async (socket: any, wantRev: number) => {
101+
const msg = await waitForCommitOrDisconnect(socket);
102+
if (msg.disconnect) {
103+
throw new Error(`Unexpected disconnect: ${JSON.stringify(msg)}`);
104+
}
105+
assert.equal(msg.data.newRev, wantRev);
106+
};
107+
108+
/**
109+
* Wait for a specific message type, ignoring others. Used for cross-user sync.
110+
*/
111+
const waitForNewChanges = (socket: any, timeoutMs = 10000): Promise<void> => {
112+
return new Promise((resolve, reject) => {
113+
const timeout = setTimeout(() => {
114+
socket.off('message', handler);
115+
reject(new Error(`timed out waiting for NEW_CHANGES after ${timeoutMs}ms`));
116+
}, timeoutMs);
117+
const handler = (msg: any) => {
118+
if (msg.type === 'COLLABROOM' && msg.data?.type === 'NEW_CHANGES') {
119+
clearTimeout(timeout);
120+
socket.off('message', handler);
121+
resolve();
122+
}
123+
};
124+
socket.on('message', handler);
125+
});
126+
};
127+
128+
describe('undo of clear authorship colors (bug #2802)', function () {
129+
it('should not disconnect when undoing clear authorship with multiple authors', async function () {
130+
this.timeout(30000);
131+
132+
// Step 1: Connect User A
133+
const userA = await connectUser();
134+
socketA = userA.socket;
135+
revA = userA.rev;
136+
137+
// Step 2: User A types "hello" with their author attribute
138+
const apoolA = new AttributePool();
139+
apoolA.putAttrib(['author', userA.author]);
140+
await Promise.all([
141+
waitForAcceptCommit(socketA, revA + 1),
142+
sendUserChanges(socketA, revA, 'Z:1>5*0+5$hello', apoolA),
143+
]);
144+
revA += 1;
145+
146+
// Step 3: Connect User B (after User A's text is committed)
147+
const userB = await connectUser();
148+
socketB = userB.socket;
149+
revB = userB.rev;
150+
151+
// Step 4: User B types " world" with their author attribute
152+
const apoolB = new AttributePool();
153+
apoolB.putAttrib(['author', userB.author]);
154+
const userASeesB = waitForNewChanges(socketA);
155+
await Promise.all([
156+
waitForAcceptCommit(socketB, revB + 1),
157+
sendUserChanges(socketB, revB, 'Z:6>6=5*0+6$ world', apoolB),
158+
]);
159+
revB += 1;
160+
161+
// Wait for User A to see User B's change
162+
await userASeesB;
163+
revA = revB;
164+
165+
// The pad now has "hello world\n" with two different authors
166+
assert.equal(pad!.text(), 'hello world\n');
167+
168+
// Step 5: User B clears authorship colors (sets author to '' on all text)
169+
const clearPool = new AttributePool();
170+
clearPool.putAttrib(['author', '']);
171+
await Promise.all([
172+
waitForAcceptCommit(socketB, revB + 1),
173+
sendUserChanges(socketB, revB, 'Z:c>0*0=b$', clearPool),
174+
]);
175+
revB += 1;
176+
177+
// Step 6: User B undoes the clear authorship
178+
// This is the critical part - the undo changeset re-applies the original
179+
// author attributes, which include User A's author ID.
180+
const undoPool = new AttributePool();
181+
undoPool.putAttrib(['author', userA.author]); // 0 = author A
182+
undoPool.putAttrib(['author', userB.author]); // 1 = author B
183+
// Undo restores: "hello" with author A (5 chars), " world" with author B (6 chars)
184+
const undoChangeset = 'Z:c>0*0=5*1=6$';
185+
186+
// This should NOT disconnect User B - that's the bug (#2802)
187+
const resultP = waitForCommitOrDisconnect(socketB);
188+
await sendUserChanges(socketB, revB, undoChangeset, undoPool);
189+
const msg = await resultP;
190+
191+
assert.notDeepEqual(msg, {disconnect: 'badChangeset'},
192+
'User was disconnected with badChangeset - bug #2802');
193+
assert.equal(msg.type, 'COLLABROOM');
194+
assert.equal(msg.data.type, 'ACCEPT_COMMIT');
195+
});
196+
197+
it('should allow clear authorship changeset with empty author from any user', async function () {
198+
// Connect one user, write text, then clear authorship
199+
const userA = await connectUser();
200+
socketA = userA.socket;
201+
revA = userA.rev;
202+
203+
// User A types text
204+
const apoolA = new AttributePool();
205+
apoolA.putAttrib(['author', userA.author]);
206+
await Promise.all([
207+
waitForAcceptCommit(socketA, revA + 1),
208+
sendUserChanges(socketA, revA, 'Z:1>5*0+5$hello', apoolA),
209+
]);
210+
revA += 1;
211+
212+
// User A clears authorship (sets author='')
213+
// This should always be allowed since author='' is not impersonation
214+
const clearPool = new AttributePool();
215+
clearPool.putAttrib(['author', '']);
216+
await Promise.all([
217+
waitForAcceptCommit(socketA, revA + 1),
218+
sendUserChanges(socketA, revA, 'Z:6>0*0=5$', clearPool),
219+
]);
220+
});
221+
222+
it('changeset restoring own author after clear should be accepted', async function () {
223+
// User clears their own authorship and then undoes (restoring own author attr)
224+
const userA = await connectUser();
225+
socketA = userA.socket;
226+
revA = userA.rev;
227+
228+
// User A types text with author attribute
229+
const apoolA = new AttributePool();
230+
apoolA.putAttrib(['author', userA.author]);
231+
await Promise.all([
232+
waitForAcceptCommit(socketA, revA + 1),
233+
sendUserChanges(socketA, revA, 'Z:1>5*0+5$hello', apoolA),
234+
]);
235+
revA += 1;
236+
237+
// User A clears authorship (sets author='')
238+
const clearPool = new AttributePool();
239+
clearPool.putAttrib(['author', '']);
240+
await Promise.all([
241+
waitForAcceptCommit(socketA, revA + 1),
242+
sendUserChanges(socketA, revA, 'Z:6>0*0=5$', clearPool),
243+
]);
244+
revA += 1;
245+
246+
// User A undoes the clear - restoring their own author attribute
247+
// This should be accepted since it's their own author ID
248+
await Promise.all([
249+
waitForAcceptCommit(socketA, revA + 1),
250+
sendUserChanges(socketA, revA, 'Z:6>0*0=5$', apoolA),
251+
]);
252+
});
253+
254+
it('changeset impersonating another author for new text should still be rejected', async function () {
255+
// Security: a user should NOT be able to write NEW text attributed to another author
256+
const userA = await connectUser();
257+
socketA = userA.socket;
258+
revA = userA.rev;
259+
260+
const userB = await connectUser();
261+
socketB = userB.socket;
262+
revB = userB.rev;
263+
264+
// User B tries to insert text attributed to User A - this should be rejected
265+
const fakePool = new AttributePool();
266+
fakePool.putAttrib(['author', userA.author]);
267+
268+
const resultP = waitForCommitOrDisconnect(socketB);
269+
await sendUserChanges(socketB, revB, 'Z:1>5*0+5$hello', fakePool);
270+
const msg = await resultP;
271+
272+
assert.deepEqual(msg, {disconnect: 'badChangeset'},
273+
'Should reject changeset that impersonates another author for new text');
274+
});
275+
276+
it('should reject = op with fabricated author who never contributed to the pad', async function () {
277+
// Security: even for = ops, reject author IDs that don't exist in the pad's pool.
278+
// This prevents attributing text to users who never touched the pad.
279+
const userA = await connectUser();
280+
socketA = userA.socket;
281+
revA = userA.rev;
282+
283+
// User A types text
284+
const apoolA = new AttributePool();
285+
apoolA.putAttrib(['author', userA.author]);
286+
await Promise.all([
287+
waitForAcceptCommit(socketA, revA + 1),
288+
sendUserChanges(socketA, revA, 'Z:1>5*0+5$hello', apoolA),
289+
]);
290+
revA += 1;
291+
292+
// User A tries to set a fabricated author ID on existing text via a = op
293+
const fakePool = new AttributePool();
294+
fakePool.putAttrib(['author', 'a.fabricatedAuthorId']);
295+
296+
const resultP = waitForCommitOrDisconnect(socketA);
297+
await sendUserChanges(socketA, revA, 'Z:6>0*0=5$', fakePool);
298+
const msg = await resultP;
299+
300+
assert.deepEqual(msg, {disconnect: 'badChangeset'},
301+
'Should reject = op with fabricated author not in pad pool');
302+
});
303+
304+
it('should reject - op with foreign author to prevent pool injection', async function () {
305+
// Security: a '-' op with a foreign author's attribs should be rejected.
306+
// While '-' attribs are discarded from the document, they are added to the
307+
// pad's attribute pool by moveOpsToNewPool. Without this check, an attacker
308+
// could inject a fabricated author ID into the pool via a '-' op, then use
309+
// a '=' op to attribute text to that fabricated author.
310+
const userA = await connectUser();
311+
socketA = userA.socket;
312+
revA = userA.rev;
313+
314+
// User A types text
315+
const apoolA = new AttributePool();
316+
apoolA.putAttrib(['author', userA.author]);
317+
await Promise.all([
318+
waitForAcceptCommit(socketA, revA + 1),
319+
sendUserChanges(socketA, revA, 'Z:1>5*0+5$hello', apoolA),
320+
]);
321+
revA += 1;
322+
323+
// User A tries to delete a char with a fabricated author attrib via a - op
324+
// This would inject the fabricated author into the pad pool
325+
const fakePool = new AttributePool();
326+
fakePool.putAttrib(['author', 'a.fabricatedAuthorId']);
327+
328+
const resultP = waitForCommitOrDisconnect(socketA);
329+
// Delete 1 char with fabricated author attrib
330+
await sendUserChanges(socketA, revA, 'Z:6<1*0-1$', fakePool);
331+
const msg = await resultP;
332+
333+
assert.deepEqual(msg, {disconnect: 'badChangeset'},
334+
'Should reject - op with fabricated author to prevent pool injection');
335+
});
336+
});
337+
});

0 commit comments

Comments
 (0)