-
Notifications
You must be signed in to change notification settings - Fork 1
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
base: poc-updates
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
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); | ||
|
||
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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. There was a problem hiding this comment. Choose a reason for hiding this commentThe 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! There was a problem hiding this comment. Choose a reason for hiding this commentThe 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, 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 There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
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") |
||
} | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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 | ||
|
@@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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} /> | ||
</> | ||
); | ||
} |
There was a problem hiding this comment.
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?There was a problem hiding this comment.
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 😄
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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?