mirror of
https://github.com/suitenumerique/docs.git
synced 2026-08-31 12:19:45 +02:00
🔒️(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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user