Skip to content

Commit 74d777f

Browse files
committed
feat: implements update rollback mechanism
1 parent 02cc748 commit 74d777f

File tree

5 files changed

+197
-6
lines changed

5 files changed

+197
-6
lines changed

hocuspocus-server/package-lock.json

Lines changed: 8 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

hocuspocus-server/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
"@blocknote/core": "^0.25.1",
2222
"@blocknote/react": "^0.25.1",
2323
"@blocknote/server-util": "^0.25.0",
24+
"@hocuspocus/common": "2.15.2",
2425
"@hocuspocus/extension-sqlite": "^2.15.2",
2526
"@hocuspocus/server": "^2.15.2",
2627
"@hono/node-server": "^1.0.8",
@@ -31,6 +32,7 @@
3132
"react": "^19.0.0",
3233
"react-dom": "^19.0.0",
3334
"y-prosemirror": "^1.2.15",
35+
"y-protocols": "1.0.6",
3436
"yjs": "^13.6.23"
3537
},
3638
"devDependencies": {

hocuspocus-server/src/index.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { cors } from "hono/cors";
88
import { createMiddleware } from "hono/factory";
99
import { FAKE_authInfoFromToken } from "./auth.js";
1010
import { threadsRouter } from "./threads.js";
11-
11+
import { RejectUnauthorized } from "./rejectUnauthorized.js";
1212
// Setup Hocuspocus server
1313
const hocuspocusServer = Server.configure({
1414
async onAuthenticate(data) {
@@ -27,6 +27,7 @@ const hocuspocusServer = Server.configure({
2727
new SQLite({
2828
database: "db.sqlite",
2929
}),
30+
new RejectUnauthorized("threads"),
3031
],
3132

3233
// TODO: for good security, you'd want to make sure that either:
@@ -69,6 +70,7 @@ const documentMiddleware = createMiddleware<{
6970
c.set("document", document);
7071

7172
await next();
73+
return;
7274
});
7375

7476
app.use("/documents/:documentId/*", documentMiddleware);
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
import type { CloseEvent } from "@hocuspocus/common";
2+
import {
3+
beforeHandleMessagePayload,
4+
Extension,
5+
IncomingMessage,
6+
MessageType,
7+
} from "@hocuspocus/server";
8+
import * as syncProtocol from "y-protocols/sync";
9+
import * as Y from "yjs";
10+
11+
/**
12+
* This extension rejects any changes to the restricted type.
13+
*
14+
* It does this by:
15+
* - extracting the yjsUpdate from the incoming message
16+
* - applying the update to the restricted type
17+
* - if the update is rejected, we throw an error and close the connection
18+
* - if the update is accepted, we do nothing
19+
*/
20+
export class RejectUnauthorized implements Extension {
21+
constructor(private readonly threadsMapKey: string) {}
22+
/**
23+
* Extract the yjsUpdate from the incoming message
24+
* @param message
25+
* @returns
26+
*/
27+
private getYUpdate(message: Uint8Array) {
28+
/**
29+
* The messages we are interested in are of the following format:
30+
* [docIdLength: number, ...docIdString: string, hocuspocusMessageType: number, ySyncMessageType: number, ...yjsUpdate: Uint8Array]
31+
*
32+
* We check that the hocuspocusMessageType is Sync and that the ySyncMessageType is messageYjsUpdate.
33+
*/
34+
const incomingMessage = new IncomingMessage(message);
35+
// Read the docID string, but don't use it
36+
incomingMessage.readVarString();
37+
38+
// Read the hocuspocusMessageType
39+
const hocuspocusMessageType = incomingMessage.readVarUint();
40+
// If the hocuspocusMessageType is not Sync, we don't handle the message, since it is not an update
41+
if (hocuspocusMessageType !== MessageType.Sync) {
42+
return;
43+
}
44+
45+
// Read the ySyncMessageType
46+
const ySyncMessageType = incomingMessage.readVarUint();
47+
48+
// If the ySyncMessageType is not messageYjsUpdate, we don't handle the message, since it is not an update
49+
if (ySyncMessageType !== syncProtocol.messageYjsUpdate) {
50+
// not an update
51+
return;
52+
}
53+
54+
// Read the yjsUpdate
55+
const yUpdate = incomingMessage.readVarUint8Array();
56+
57+
return yUpdate;
58+
}
59+
60+
/**
61+
* This function protects against changes to the restricted type.
62+
* It does this by:
63+
* - setting up an undo manager on the restricted type
64+
* - caching pending updates from the Ydoc to avoid certain attacks
65+
* - applying the received update and checking whether the restricted type has been changed
66+
* - catching errors that might try to circumvent the restrictions
67+
* - undoing changes on restricted types
68+
* - reapplying pending updates
69+
*
70+
* @param yUpdate The update to apply
71+
* @param ydoc The document that the update is being applied to
72+
* @param restrictedType The type that we want to protect
73+
* @returns true if the update was rejected, false otherwise
74+
*/
75+
private rollbackUpdateIfNeeded(
76+
yUpdate: Uint8Array,
77+
ydoc: Y.Doc,
78+
restrictedType: Y.AbstractType<any>
79+
) {
80+
// don't handle changes of the local undo manager, which is used to undo invalid changes
81+
const um = new Y.UndoManager(restrictedType, {
82+
trackedOrigins: new Set(["remote change"]),
83+
});
84+
const beforePendingDs = ydoc.store.pendingDs;
85+
const beforePendingStructs = ydoc.store.pendingStructs?.update;
86+
let didNeedToUndo = false;
87+
try {
88+
Y.applyUpdate(ydoc, yUpdate, "remote change");
89+
} finally {
90+
while (um.undoStack.length) {
91+
um.undo();
92+
didNeedToUndo = true;
93+
}
94+
um.destroy();
95+
ydoc.store.pendingDs = beforePendingDs;
96+
ydoc.store.pendingStructs = null;
97+
if (beforePendingStructs) {
98+
Y.applyUpdateV2(ydoc, beforePendingStructs);
99+
}
100+
}
101+
102+
return didNeedToUndo;
103+
}
104+
105+
async beforeHandleMessage({
106+
update,
107+
document: ydoc,
108+
}: beforeHandleMessagePayload) {
109+
const yUpdate = this.getYUpdate(update);
110+
111+
if (!yUpdate) {
112+
return;
113+
}
114+
115+
const protectedType = ydoc.getMap(this.threadsMapKey);
116+
const didNeedToUndo = this.rollbackUpdateIfNeeded(
117+
yUpdate,
118+
ydoc,
119+
protectedType
120+
);
121+
122+
if (didNeedToUndo) {
123+
// TODO, we can close their connection or just let them continue, since we've already undone their changes (and our changes are newer than theirs)
124+
const error = {
125+
reason: `Modification of a restricted type: ${this.threadsMapKey} was rejected`,
126+
} satisfies Partial<CloseEvent>;
127+
throw error;
128+
}
129+
}
130+
}

next-app/components/Editor.tsx

Lines changed: 54 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,12 @@ import { BlockNoteView } from "@blocknote/mantine";
88
import "@blocknote/mantine/style.css";
99
import { useCreateBlockNote } from "@blocknote/react";
1010
import { HocuspocusProvider } from "@hocuspocus/provider";
11+
import { useEffect } from "react";
1112

1213
// Hardcoded settings for demo purposes
1314
const USER_ID = "user123";
1415
const USER_ROLE: "COMMENT-ONLY" | "READ-WRITE" = "READ-WRITE";
15-
const DOCUMENT_ID = "mydoc123";
16+
const DOCUMENT_ID = "mydoc1234";
1617
const TOKEN = `${USER_ID}__${USER_ROLE}`;
1718

1819
// Setup Hocuspocus provider
@@ -73,6 +74,57 @@ export default function Editor() {
7374
},
7475
});
7576

77+
useEffect(() => {
78+
provider.document.on("update", (update) => {
79+
console.log(provider.document.getMap("threads").toJSON());
80+
});
81+
}, [provider.document]);
82+
7683
// Renders the editor instance using a React component.
77-
return <BlockNoteView editor={editor} />;
84+
return (
85+
<>
86+
<button
87+
onClick={() => {
88+
const comments = provider.document.getMap("threads");
89+
comments.set("unauthorized-thread", {
90+
id: "unauthorized-thread",
91+
createdAt: 1741265978860,
92+
updatedAt: 1741265978860,
93+
comments: [
94+
{
95+
id: "unauthorized-comment",
96+
userId: "unauthorized-user",
97+
createdAt: 1741265978860,
98+
updatedAt: 1741265978860,
99+
body: [
100+
{
101+
id: "unauthorized-comment-body",
102+
type: "paragraph",
103+
props: {},
104+
content: [
105+
{
106+
type: "text",
107+
text: "This comment should not be visible",
108+
styles: {},
109+
},
110+
],
111+
children: [],
112+
},
113+
],
114+
reactionsByUser: {},
115+
},
116+
],
117+
resolved: false,
118+
});
119+
}}
120+
>
121+
Unauthorized comment modification
122+
</button>
123+
<p>
124+
Pressing the button above will add a new comment to the threads map, but
125+
this change will be rejected by the server.
126+
</p>
127+
<BlockNoteView editor={editor} />
128+
</>
129+
);
78130
}

0 commit comments

Comments
 (0)