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

Conversation

nperez0111
Copy link

This resolves #1 by implementing a rollback mechanism for hocuspocus servers.

This will inspect a hocuspocus sync, which contains a ysync update, and check that there are no modifications to the threads map.
This will ensure that a malicious client cannot modify the threads map and it can only ever be manipulated through the REST server.

Comment on lines +123 to +127
// 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;
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")

Comment on lines +77 to +81
useEffect(() => {
provider.document.on("update", (update) => {
console.log(provider.document.getMap("threads").toJSON());
});
}, [provider.document]);
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

Comment on lines +87 to +89
onClick={() => {
const comments = provider.document.getMap("threads");
comments.set("unauthorized-thread", {
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

Comment on lines 27 to 58
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) {
return;
}

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

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

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

return yUpdate;
}
Copy link
Author

Choose a reason for hiding this comment

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

This took forever to figure out 😮‍💨

@nperez0111 nperez0111 requested a review from YousefED March 11, 2025 14:10
Copy link
Contributor

@YousefED YousefED left a comment

Choose a reason for hiding this comment

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

Nice you got this to work. Not sure yet whether this approach is feasible though:

  • see my comment in beforeHandleMessage; if we're applying updates twice that would be a problem
  • if we're also missing other messages (see comments about SyncReply and messageYjsSyncStep1) this approach will get quite error-prone

let's discuss

// 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) {
Copy link
Contributor

Choose a reason for hiding this comment

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

Copy link
Author

Choose a reason for hiding this comment

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

Maybe? I'm not familiar with what this bit is doing totally

Copy link
Author

Choose a reason for hiding this comment

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

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?

@YousefED
Copy link
Contributor

@nperez0111 wdyt of something like this? https://github.com/ueberdosis/hocuspocus/compare/main...YousefED:hocuspocus:feat/custom-messagereceiver?expand=1

Not sure it will land of course, but I think this should make it possible to customize the messagereceiver and wrap / overload the apply method with the checks we require?

@nperez0111
Copy link
Author

@nperez0111 wdyt of something like this? https://github.com/ueberdosis/hocuspocus/compare/main...YousefED:hocuspocus:feat/custom-messagereceiver?expand=1

Not sure it will land of course, but I think this should make it possible to customize the messagereceiver and wrap / overload the apply method with the checks we require?

Yea, I think that something like that would work, and probably worthwhile to try to get in.

@YousefED
Copy link
Contributor

Nice. @nperez0111 do you want to try first whether it works before I (or you) fire up a PR?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants