Skip to content

feat: implements update rollback mechanism #3

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 2 commits into
base: poc-updates
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions hocuspocus-server/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions hocuspocus-server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
"@blocknote/core": "^0.25.1",
"@blocknote/react": "^0.25.1",
"@blocknote/server-util": "^0.25.0",
"@hocuspocus/common": "2.15.2",
"@hocuspocus/extension-sqlite": "^2.15.2",
"@hocuspocus/server": "^2.15.2",
"@hono/node-server": "^1.0.8",
Expand All @@ -31,6 +32,7 @@
"react": "^19.0.0",
"react-dom": "^19.0.0",
"y-prosemirror": "^1.2.15",
"y-protocols": "1.0.6",
"yjs": "^13.6.23"
},
"devDependencies": {
Expand Down
4 changes: 3 additions & 1 deletion hocuspocus-server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { cors } from "hono/cors";
import { createMiddleware } from "hono/factory";
import { FAKE_authInfoFromToken } from "./auth.js";
import { threadsRouter } from "./threads.js";

import { RejectUnauthorized } from "./rejectUnauthorized.js";
// Setup Hocuspocus server
const hocuspocusServer = Server.configure({
async onAuthenticate(data) {
Expand All @@ -27,6 +27,7 @@ const hocuspocusServer = Server.configure({
new SQLite({
database: "db.sqlite",
}),
new RejectUnauthorized("threads"),
],

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

await next();
return;
});

app.use("/documents/:documentId/*", documentMiddleware);
Expand Down
140 changes: 140 additions & 0 deletions hocuspocus-server/src/rejectUnauthorized.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
import type { CloseEvent } from "@hocuspocus/common";
import {
beforeHandleMessagePayload,
Extension,
IncomingMessage,
MessageType,
} from "@hocuspocus/server";
import * as syncProtocol from "y-protocols/sync";
import * as Y from "yjs";

/**
* This extension rejects any changes to the restricted type.
*
* It does this by:
* - extracting the yjsUpdate from the incoming message
* - applying the update to the restricted type
* - if the update is rejected, we throw an error and close the connection
* - if the update is accepted, we do nothing
*/
export class RejectUnauthorized implements Extension {
constructor(private readonly threadsMapKey: string) {}
/**
* Extract the yjsUpdate from the incoming message
* @param message
* @returns
*/
private getYUpdate(message: Uint8Array) {
/**
* The messages we are interested in are of the following format:
* [docIdLength: number, ...docIdString: string, hocuspocusMessageType: number, ySyncMessageType: number, ...yjsUpdate: Uint8Array]
*
* We check that the hocuspocusMessageType is Sync and that the ySyncMessageType is messageYjsUpdate.
*/
const incomingMessage = new IncomingMessage(message);
// Read the docID string, but don't use it
incomingMessage.readVarString();

// Read the hocuspocusMessageType
const hocuspocusMessageType = incomingMessage.readVarUint();
// If the hocuspocusMessageType is not Sync, we don't handle the message, since it is not an update
if (
!(
hocuspocusMessageType === MessageType.Sync ||
hocuspocusMessageType === MessageType.SyncReply
)
) {
return;
}

// Read the ySyncMessageType
const ySyncMessageType = incomingMessage.readVarUint();

// If the ySyncMessageType is not a messageYjsUpdate or a messageYjsSyncStep2, we don't handle the message, since it is not an update
if (
!(
ySyncMessageType === syncProtocol.messageYjsUpdate ||
ySyncMessageType === syncProtocol.messageYjsSyncStep2
)
) {
// not an update we want to handle
return;
}

// Read the yjsUpdate
const yUpdate = incomingMessage.readVarUint8Array();

return yUpdate;
}

/**
* This function protects against changes to the restricted type.
* It does this by:
* - setting up an undo manager on the restricted type
* - caching pending updates from the Ydoc to avoid certain attacks
* - applying the received update and checking whether the restricted type has been changed
* - catching errors that might try to circumvent the restrictions
* - undoing changes on restricted types
* - reapplying pending updates
*
* @param yUpdate The update to apply
* @param ydoc The document that the update is being applied to
* @param restrictedType The type that we want to protect
* @returns true if the update was rejected, false otherwise
*/
private applyUpdateAndRollbackIfNeeded(
yUpdate: Uint8Array,
ydoc: Y.Doc,
restrictedType: Y.AbstractType<any>
) {
// don't handle changes of the local undo manager, which is used to undo invalid changes
const um = new Y.UndoManager(restrictedType, {
trackedOrigins: new Set(["remote change"]),
});
const beforePendingDs = ydoc.store.pendingDs;
const beforePendingStructs = ydoc.store.pendingStructs?.update;
let didNeedToUndo = false;
try {
Y.applyUpdate(ydoc, yUpdate, "remote change");
} finally {
while (um.undoStack.length) {
um.undo();
didNeedToUndo = true;
}
um.destroy();
ydoc.store.pendingDs = beforePendingDs;
ydoc.store.pendingStructs = null;
if (beforePendingStructs) {
Y.applyUpdateV2(ydoc, beforePendingStructs);
}
}

return didNeedToUndo;
}

async beforeHandleMessage({
update,
document: ydoc,
}: beforeHandleMessagePayload) {
const yUpdate = this.getYUpdate(update);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see we process the update here (and in rollbackUpdateIfNeeded manually). Wouldn't we now apply the update twice? i.e.: the regular hocuspocus code will still process the message, right? so everything would be processed twice?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The only way that I could think of not applying the update twice would be to make a whole copy of the ydoc.

Applying updates twice should be fine, no? It is a CRDT 😄

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's fine to apply the update twice.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

to be clear; this means all updates will be applied twice. Wouldn't that double the load?


if (!yUpdate) {
return;
}

const protectedType = ydoc.getMap(this.threadsMapKey);
const didRollback = this.applyUpdateAndRollbackIfNeeded(
yUpdate,
ydoc,
protectedType
);

if (didRollback) {
// 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)
const error = {
reason: `Modification of a restricted type: ${this.threadsMapKey} was rejected`,
} satisfies Partial<CloseEvent>;
throw error;
Comment on lines +133 to +137
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can close their connection or let them continue. Closing their connection does have the benefit that it forces them to re-sync so I went with that.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since you are undoing, the client will receive a message that reverses their changes (Assuming Hocuspocus distributes all changes that were applied to a Y.Doc).

But it would be good to know somehow when this happens, because it shouldn't!

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm sorry I don't fully understand what you mean by:

But it would be good to know somehow when this happens, because it shouldn't!

But, to address your first point: hocuspocus would distribute this change if it was modified. But, with this implementation, it bails out early and closes the connection with that client. The hocuspocus provider notices that the connection has been closed, and will automatically retry to open a new connection. I think that what happens here is that they attempt to sync again, and the document on the hocuspocus server already includes that "invalid" update and so they can continue to sync from there.

So, either way, the connection seems to continue, so maybe we should not kill the connection? Though, I don't really like just taking the update and not letting the user know that it was invalid. At least closing the connection makes it clear that the client did something invalid.

Happy to take opinions on this though

Copy link
Contributor

@YousefED YousefED Mar 12, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can close their connection or let them continue.

I don't think we can let them continue because HocusPocus will apply the update again as per https://github.com/TypeCellOS/BlockNote-demo-nextjs-hocuspocus/pull/3/files#r1990990264

Update: I'm probably wrong here, because the second update will be a noop as it's already been applied (even though it has been "undone")

}
}
}
56 changes: 54 additions & 2 deletions next-app/components/Editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,12 @@ import { BlockNoteView } from "@blocknote/mantine";
import "@blocknote/mantine/style.css";
import { useCreateBlockNote } from "@blocknote/react";
import { HocuspocusProvider } from "@hocuspocus/provider";
import { useEffect } from "react";

// Hardcoded settings for demo purposes
const USER_ID = "user123";
const USER_ROLE: "COMMENT-ONLY" | "READ-WRITE" = "READ-WRITE";
const DOCUMENT_ID = "mydoc123";
const DOCUMENT_ID = "mydoc1234";
const TOKEN = `${USER_ID}__${USER_ROLE}`;

// Setup Hocuspocus provider
Expand Down Expand Up @@ -73,6 +74,57 @@ export default function Editor() {
},
});

useEffect(() => {
provider.document.on("update", (update) => {
console.log(provider.document.getMap("threads").toJSON());
});
}, [provider.document]);
Comment on lines +77 to +81
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This just shows the current client state in an easy to inspect way


// Renders the editor instance using a React component.
return <BlockNoteView editor={editor} />;
return (
<>
<button
onClick={() => {
const comments = provider.document.getMap("threads");
comments.set("unauthorized-thread", {
Comment on lines +87 to +89
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

simulating a malicious client

id: "unauthorized-thread",
createdAt: 1741265978860,
updatedAt: 1741265978860,
comments: [
{
id: "unauthorized-comment",
userId: "unauthorized-user",
createdAt: 1741265978860,
updatedAt: 1741265978860,
body: [
{
id: "unauthorized-comment-body",
type: "paragraph",
props: {},
content: [
{
type: "text",
text: "This comment should not be visible",
styles: {},
},
],
children: [],
},
],
reactionsByUser: {},
},
],
resolved: false,
});
}}
>
Unauthorized comment modification
</button>
<p>
Pressing the button above will add a new comment to the threads map, but
this change will be rejected by the server.
</p>
<BlockNoteView editor={editor} />
</>
);
}