diff --git a/CHANGELOG.md b/CHANGELOG.md
index 508f29c6a..7a4fd624a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -18,6 +18,10 @@ and this project adheres to
- 🐛(backend) ignore CSPs for API docs in development
- 🐛(frontend) export images embedded with a relative url #2573
+### Security
+
+- 🔒️(yprovider) readOnly connections awareness propagation #2544
+
### Changed
- ♿️(frontend) use semantic `
` structure in document info card #2379
diff --git a/src/frontend/servers/y-provider/__tests__/hocuspocusWS.test.ts b/src/frontend/servers/y-provider/__tests__/hocuspocusWS.test.ts
index 16d6c929d..8d5c59cb6 100644
--- a/src/frontend/servers/y-provider/__tests__/hocuspocusWS.test.ts
+++ b/src/frontend/servers/y-provider/__tests__/hocuspocusWS.test.ts
@@ -337,6 +337,167 @@ describe('Server Tests', () => {
});
});
+ test('Read-only connection cannot propagate document updates or awareness', () => {
+ const { promise, done } = promiseDone();
+
+ vi.spyOn(CollaborationBackend, 'fetchDocument').mockResolvedValue({
+ abilities: { retrieve: true, update: false },
+ } as any);
+
+ const room = uuidv4();
+ const wsHocus = new HocuspocusProviderWebsocket({
+ url: `ws://localhost:${portWS}/?room=${room}`,
+ WebSocketPolyfill: WebSocket,
+ });
+
+ const provider = new HocuspocusProvider({
+ websocketProvider: wsHocus,
+ name: room,
+ onSynced: () => {
+ // Attempt a document change.
+ provider.document.getText('test').insert(0, 'hacked');
+
+ // Attempt an awareness broadcast (e.g. cursor/selection presence).
+ provider.awareness?.setLocalStateField('user', {
+ name: 'Reader',
+ color: '#22aa44',
+ });
+
+ setTimeout(() => {
+ // The connection itself must not be cut.
+ expect(wsHocus.webSocket?.readyState).toBe(WebSocket.OPEN);
+
+ const document = hocuspocusServer.hocuspocus.documents.get(room);
+
+ // The document update must never reach the shared document.
+ expect(document?.getText('test').toString()).toBe('');
+
+ // The awareness update must never reach the shared document either.
+ const clientId = provider.awareness?.clientID as number;
+ expect(document?.awareness.getStates().has(clientId)).toBe(false);
+
+ provider.destroy();
+ wsHocus.destroy();
+
+ done();
+ }, 200);
+ },
+ });
+
+ provider.attach();
+
+ return promise;
+ });
+
+ test("Read-only connection catches up on another client's unsaved changes via sync", () => {
+ const { promise, done } = promiseDone();
+
+ const room = uuidv4();
+
+ vi.spyOn(CollaborationBackend, 'fetchDocument').mockResolvedValue({
+ abilities: { retrieve: true, update: true },
+ } as any);
+
+ const editorWs = new HocuspocusProviderWebsocket({
+ url: `ws://localhost:${portWS}/?room=${room}`,
+ WebSocketPolyfill: WebSocket,
+ });
+
+ const editorProvider = new HocuspocusProvider({
+ websocketProvider: editorWs,
+ name: room,
+ onSynced: () => {
+ // The change only lives in the server's in-memory Y.Doc at this
+ // point - nothing persists/reloads it from a database in this test.
+ editorProvider.document.getText('test').insert(0, 'unsaved-content');
+
+ setTimeout(() => {
+ vi.spyOn(CollaborationBackend, 'fetchDocument').mockResolvedValue({
+ abilities: { retrieve: true, update: false },
+ } as any);
+
+ const readerWs = new HocuspocusProviderWebsocket({
+ url: `ws://localhost:${portWS}/?room=${room}`,
+ WebSocketPolyfill: WebSocket,
+ });
+
+ const readerProvider = new HocuspocusProvider({
+ websocketProvider: readerWs,
+ name: room,
+ onSynced: () => {
+ // The reader must catch up via its own SyncStep1, not just
+ // whatever it happened to have locally beforehand.
+ expect(readerProvider.document.getText('test').toString()).toBe(
+ 'unsaved-content',
+ );
+
+ editorProvider.destroy();
+ editorWs.destroy();
+ readerProvider.destroy();
+ readerWs.destroy();
+
+ done();
+ },
+ });
+
+ readerProvider.attach();
+ }, 200);
+ },
+ });
+
+ editorProvider.attach();
+
+ return promise;
+ });
+
+ test('Editable connection propagates document updates and awareness', () => {
+ const { promise, done } = promiseDone();
+
+ vi.spyOn(CollaborationBackend, 'fetchDocument').mockResolvedValue({
+ abilities: { retrieve: true, update: true },
+ } as any);
+
+ const room = uuidv4();
+ const wsHocus = new HocuspocusProviderWebsocket({
+ url: `ws://localhost:${portWS}/?room=${room}`,
+ WebSocketPolyfill: WebSocket,
+ });
+
+ const provider = new HocuspocusProvider({
+ websocketProvider: wsHocus,
+ name: room,
+ onSynced: () => {
+ provider.document.getText('test').insert(0, 'hello');
+
+ provider.awareness?.setLocalStateField('user', {
+ name: 'Editor',
+ color: '#22aa44',
+ });
+
+ setTimeout(() => {
+ expect(wsHocus.webSocket?.readyState).toBe(WebSocket.OPEN);
+
+ const document = hocuspocusServer.hocuspocus.documents.get(room);
+
+ expect(document?.getText('test').toString()).toBe('hello');
+
+ const clientId = provider.awareness?.clientID as number;
+ const clientState = document?.awareness.getStates().get(clientId);
+ expect(clientState?.user?.color).toBe('#22aa44');
+
+ provider.destroy();
+ wsHocus.destroy();
+
+ done();
+ }, 200);
+ },
+ });
+
+ provider.attach();
+
+ return promise;
+ });
+
test('Add request header x-user-id if found', () => {
const { promise, done } = promiseDone();
diff --git a/src/frontend/servers/y-provider/src/servers/hocuspocusServer.ts b/src/frontend/servers/y-provider/src/servers/hocuspocusServer.ts
index d60ec1947..8070ec85b 100644
--- a/src/frontend/servers/y-provider/src/servers/hocuspocusServer.ts
+++ b/src/frontend/servers/y-provider/src/servers/hocuspocusServer.ts
@@ -1,9 +1,39 @@
-import { Server } from '@hocuspocus/server';
+import {
+ Connection,
+ Document,
+ MessageReceiver,
+ MessageType,
+ Server,
+} from '@hocuspocus/server';
import { validate as uuidValidate, version as uuidVersion } from 'uuid';
import { fetchCurrentUser, fetchDocument } from '@/api/collaborationBackend';
import { logger } from '@/utils';
+/**
+ * Override the default apply method of MessageReceiver to prevent applying
+ * awareness updates from read-only connections.
+ */
+const originalApply = MessageReceiver.prototype.apply;
+MessageReceiver.prototype.apply = function (
+ this: MessageReceiver,
+ document: Document,
+ connection?: Connection,
+ reply?: (message: Uint8Array) => void,
+) {
+ if (connection?.readOnly) {
+ const startPos = this.message.decoder.pos;
+ const type = this.message.readVarUint();
+ this.message.decoder.pos = startPos;
+
+ if (type === MessageType.Awareness) {
+ return type;
+ }
+ }
+
+ return originalApply.call(this, document, connection, reply);
+};
+
export const hocuspocusServer = new Server({
name: 'docs-collaboration',
timeout: 30000,