Skip to content

Commit 805766c

Browse files
JohnMcLearclaude
andcommitted
harden: reject USER_CHANGES that would strand the trailing newline
Etherpad's pad text always ends with '\n'. _handleUserChanges previously appended a separate `nlChangeset` correction revision whenever the applied USER_CHANGES left the pad without a trailing '\n'. The stored pad ended up well-formed, but the FIRST NEW_CHANGES broadcast (the malformed user revision itself) reached browsers BEFORE the correction did, and applyToAttribution's MergingOpAssembler aborts with "line assembler not finished" on a non-'\n'-terminated doc — the watching browser session then dropped the changeset and any subsequent edits silently no-op'd until the user reloaded. Replace the silent auto-correction with an explicit reject. Compute `applyToText(rebasedChangeset, prevText)` before appendRevision; if the result doesn't end with '\n', throw -> badChangeset disconnect. Clients must emit USER_CHANGES whose application preserves the invariant — this matches what the JS web client already does and forces non-JS clients (etherpad-pad, third-party integrations) to surface their bugs in their own logs instead of stranding the trailing newline in pad revision history. Also fixes a latent retransmission-detection bug surfaced by this PR's author-attrib changes: moveOpsToNewPool renumbers `*N` references to whatever slot the pad pool assigns, which can differ from the wire form's slot. Comparing the raw client wire against the stored revision form (`changeset === c`) then misses legitimate retransmissions and the same edit gets duplicated. Snapshot the post-pool-mapping form (`canonicalCs`) and compare that against `c` instead. Backend test additions: - 'changeset that would strand the trailing \\n is rejected' covers the new rejection path with wire `Z:6>1|1=6*0+1$X` against `hello\n`. - handleMessageSecurity test now captures roSocket's own authorId and uses it in the apool sent through roSocket, because the prior PR commit made `*0` referencing the wrong author a hard reject. All 1130 backend tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 631d8c5 commit 805766c

2 files changed

Lines changed: 71 additions & 13 deletions

File tree

src/node/handler/PadMessageHandler.ts

Lines changed: 25 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ import {MapArrayType} from "../types/MapType";
2424
import AttributeMap from '../../static/js/AttributeMap';
2525
const padManager = require('../db/PadManager');
2626
const padDeletionManager = require('../db/PadDeletionManager');
27-
import {checkRep, cloneAText, compose, deserializeOps, follow, identity, inverse, makeAText, makeSplice, moveOpsToNewPool, mutateAttributionLines, mutateTextLines, oldLen, prepareForWire, splitAttributionLines, splitTextLines, unpack} from '../../static/js/Changeset';
27+
import {applyToText, checkRep, cloneAText, compose, deserializeOps, follow, identity, inverse, makeAText, moveOpsToNewPool, mutateAttributionLines, mutateTextLines, oldLen, prepareForWire, splitAttributionLines, splitTextLines, unpack} from '../../static/js/Changeset';
2828
import ChatMessage from '../../static/js/ChatMessage';
2929
import AttributePool from '../../static/js/AttributePool';
3030
const AttributeManager = require('../../static/js/AttributeManager');
@@ -893,6 +893,12 @@ const handleUserChanges = async (socket:any, message: {
893893

894894
// Afaik, it copies the new attributes from the changeset, to the global Attribute Pool
895895
let rebasedChangeset = moveOpsToNewPool(changeset, wireApool, pad.pool);
896+
// Snapshot the post-pool-mapping form so the retransmission check below
897+
// can recognise our changeset against the stored revision form. Comparing
898+
// the raw client `changeset` against `c` would miss legitimate
899+
// retransmissions whenever moveOpsToNewPool renumbered an attribute
900+
// (e.g. `*0` -> `*1` because the pad pool already had something at slot 0).
901+
const canonicalCs = rebasedChangeset;
896902

897903
// ex. applyUserChanges
898904
let r = baseRev;
@@ -903,9 +909,9 @@ const handleUserChanges = async (socket:any, message: {
903909
while (r < pad.getHeadRevisionNumber()) {
904910
r++;
905911
const {changeset: c, meta: {author: authorId}} = await pad.getRevision(r);
906-
if (changeset === c && thisSession.author === authorId) {
912+
if (canonicalCs === c && thisSession.author === authorId) {
907913
// Assume this is a retransmission of an already applied changeset.
908-
rebasedChangeset = identity(unpack(changeset).oldLen);
914+
rebasedChangeset = identity(unpack(canonicalCs).oldLen);
909915
}
910916
// At this point, both "c" (from the pad) and "changeset" (from the
911917
// client) are relative to revision r - 1. The follow function
@@ -921,6 +927,22 @@ const handleUserChanges = async (socket:any, message: {
921927
`Can't apply changeset ${rebasedChangeset} with oldLen ` +
922928
`${oldLen(rebasedChangeset)} to document of length ${prevText.length}`);
923929
}
930+
// Defensive: reject any rebased changeset whose application would leave
931+
// the pad text not ending with '\n'. Previously the server silently
932+
// appended a separate `nlChangeset` correction revision; that worked
933+
// for the stored pad but the FIRST broadcast (the malformed user
934+
// revision) reached browsers BEFORE the correction did, and the
935+
// browser's line assembler asserts "line assembler not finished" on
936+
// a doc that doesn't end with '\n', taking the session out. Refuse to
937+
// accept such changesets — clients must always preserve the
938+
// trailing-newline invariant.
939+
const projectedText = applyToText(rebasedChangeset, prevText);
940+
if (!projectedText.endsWith('\n')) {
941+
throw new Error(
942+
`Rejected USER_CHANGES whose application would leave the pad ` +
943+
`without a trailing '\\n' (length ${projectedText.length}). ` +
944+
`Every USER_CHANGES must preserve the "doc ends with \\n" invariant.`);
945+
}
924946

925947
const newRev = await pad.appendRevision(rebasedChangeset, thisSession.author);
926948
// The head revision will either stay the same or increase by 1 depending on whether the
@@ -932,12 +954,6 @@ const handleUserChanges = async (socket:any, message: {
932954
await pad.appendRevision(correctionChangeset, thisSession.author);
933955
}
934956

935-
// Make sure the pad always ends with an empty line.
936-
if (pad.text().lastIndexOf('\n') !== pad.text().length - 1) {
937-
const nlChangeset = makeSplice(pad.text(), pad.text().length - 1, 0, '\n');
938-
await pad.appendRevision(nlChangeset, thisSession.author);
939-
}
940-
941957
// The client assumes that ACCEPT_COMMIT and NEW_CHANGES messages arrive in order. Make sure we
942958
// have already sent any previous ACCEPT_COMMIT and NEW_CHANGES messages.
943959
assert.equal(thisSession.rev, r);

src/tests/backend/specs/messages.ts

Lines changed: 46 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ describe(__filename, function () {
2424
});
2525

2626
let authorId: string;
27+
let roAuthorId: string;
2728
beforeEach(async function () {
2829
backups.hooks = {handleMessageSecurity: plugins.hooks.handleMessageSecurity};
2930
plugins.hooks.handleMessageSecurity = [];
@@ -42,7 +43,13 @@ describe(__filename, function () {
4243
roPadId = await readOnlyManager.getReadOnlyId(padId);
4344
res = await agent.get(`/p/${roPadId}`).expect(200);
4445
roSocket = await common.connect(res);
45-
await common.handshake(roSocket, roPadId);
46+
const roHandshake = await common.handshake(roSocket, roPadId);
47+
// Capture roSocket's own author so tests that send USER_CHANGES via
48+
// roSocket can build apools that match thisSession.author server-side;
49+
// otherwise the wire's *0 reference points at the writer-socket's
50+
// author and the server rejects with badChangeset on the
51+
// "author mismatch" check added in this PR.
52+
roAuthorId = (roHandshake as any).data.userId;
4653
await new Promise(resolve => setTimeout(resolve, 1000));
4754
});
4855

@@ -214,6 +221,34 @@ describe(__filename, function () {
214221
]);
215222
});
216223

224+
it('changeset that would strand the trailing \\n is rejected', async function () {
225+
// Defensive validation: every USER_CHANGES must leave the pad ending
226+
// with '\n'. The pre-existing handler used to auto-append a
227+
// correction revision when the trailing '\n' got stranded, but the
228+
// first NEW_CHANGES broadcast (the malformed user revision) reached
229+
// browsers BEFORE the correction did, and the browser's line
230+
// assembler asserts "line assembler not finished" on a non-'\n'-
231+
// terminated doc — kicking the watching session offline. Refuse such
232+
// changesets up front instead.
233+
//
234+
// Seed the pad as 'hello\n' (6 chars, 1 line), then send a changeset
235+
// that explicitly keeps all 6 chars (consuming the trailing '\n')
236+
// and inserts 'X' AFTER. Projected text = 'hello\nX' — doesn't end
237+
// with '\n', must be rejected. The keep spans 1 newline so the wire
238+
// must carry the `|1` marker to be in canonical form.
239+
await Promise.all([
240+
assertAccepted(socket, rev + 1),
241+
sendUserChanges(socket, 'Z:1>5*0+5$hello'),
242+
]);
243+
assert.equal(pad!.text(), 'hello\n');
244+
await Promise.all([
245+
assertRejected(socket),
246+
sendUserChanges(socket, 'Z:6>1|1=6*0+1$X'),
247+
]);
248+
// Pad must be unchanged after the rejection.
249+
assert.equal(pad!.text(), 'hello\n');
250+
});
251+
217252
it('retransmission is accepted, has no effect', async function () {
218253
const cs = 'Z:1>5*0+5$hello';
219254
await Promise.all([
@@ -254,9 +289,15 @@ describe(__filename, function () {
254289

255290
it('handleMessageSecurity can grant one-time write access', async function () {
256291
const cs = 'Z:1>5*0+5$hello';
292+
// Use roSocket's own author in the apool so the *0 reference matches
293+
// thisSession.author server-side. Without this, the new author-attrib
294+
// validation rejects the changeset before handleMessageSecurity gets
295+
// a chance to permit it.
296+
const roPool = () =>
297+
({numToAttrib: {0: ['author', roAuthorId]}, nextNum: 1});
257298
const errRegEx = /write attempt on read-only pad/;
258299
// First try to send a change and verify that it was dropped.
259-
await assert.rejects(sendUserChanges(roSocket, cs), errRegEx);
300+
await assert.rejects(sendUserChanges(roSocket, cs, roPool()), errRegEx);
260301
// sendUserChanges() waits for message ack, so if the message was accepted then head should
261302
// have already incremented by the time we get here.
262303
assert.equal(pad!.head, rev); // Not incremented.
@@ -265,13 +306,14 @@ describe(__filename, function () {
265306
plugins.hooks.handleMessageSecurity.push({hook_fn: () => 'permitOnce'});
266307
await Promise.all([
267308
assertAccepted(roSocket, rev + 1),
268-
sendUserChanges(roSocket, cs),
309+
sendUserChanges(roSocket, cs, roPool()),
269310
]);
270311
assert.equal(pad!.text(), 'hello\n');
271312

272313
// The next change should be dropped.
273314
plugins.hooks.handleMessageSecurity = [];
274-
await assert.rejects(sendUserChanges(roSocket, 'Z:6>6=5*0+6$ world'), errRegEx);
315+
await assert.rejects(
316+
sendUserChanges(roSocket, 'Z:6>6=5*0+6$ world', roPool()), errRegEx);
275317
assert.equal(pad!.head, rev); // Not incremented.
276318
assert.equal(pad!.text(), 'hello\n');
277319
});

0 commit comments

Comments
 (0)