🔒️(yprovider) readOnly connections awareness propagation

We noticed that a read-only connection was still able
to propagate awareness updates (cursor/selection presence)
to other clients, even though connectionConfig.readOnly
already blocked document sync updates.

Override MessageReceiver.apply to no-op for any read-only
connection instead of delegating to the original
implementation, so document awareness updates never propagate
from it.
The connection itself is left open; only the
awareness is dropped.
This commit is contained in:
Anthony LC
2026-08-11 15:54:50 +02:00
parent 28438fdd92
commit 369ea1a4b9
3 changed files with 196 additions and 1 deletions
+4
View File
@@ -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 `<dl>` structure in document info card #2379
@@ -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();
@@ -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,