fix(server): sync permission check (#15123)
fix #15121 #### PR Dependency Tree * **PR #15123** 👈 This tree was auto-generated by [Charcoal](https://github.com/danerwilliams/charcoal) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Security Improvements** * Enforced document-level `Doc.Read`/`Doc.Update` checks for key sync websocket operations, including filtering workspace doc timestamp results to only readable documents. * Improved remote permission handling: once a remote denies access, syncing stops for the affected document and retry behavior is suppressed. * **Improvements** * `delete-doc` now relies on server acknowledgment and returns an explicit `{ success: true }`. * Websocket acknowledgment errors are now normalized for consistent error details. * **Tests** * Expanded permission-denied and websocket error-handling coverage, including timestamp filtering and no-retry behavior after permission denial. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
1 parent
da7781a751
commit
1256d66938
6 files changed
+666
-42
No files matched your search
@@ -33,6 +33,7 @@ import {
|
||||
SpaceStorage,
|
||||
} from '../storage';
|
||||
import { Sync } from '../sync';
|
||||
import { DocSyncPeer } from '../sync/doc/peer';
|
||||
import { IndexerSyncImpl } from '../sync/indexer';
|
||||
import { expectYjsEqual } from './utils';
|
||||
|
||||
@@ -112,6 +113,64 @@ class TestDocStorage implements DocStorage {
|
||||
}
|
||||
}
|
||||
|
||||
class PermissionDeniedRemoteDocStorage implements DocStorage {
|
||||
readonly storageType = 'doc' as const;
|
||||
readonly connection = new DummyConnection();
|
||||
readonly isReadonly = false;
|
||||
pushCount = 0;
|
||||
|
||||
constructor(readonly spaceId: string) {}
|
||||
|
||||
async getDoc(_docId: string): Promise<DocRecord | null> {
|
||||
return null;
|
||||
}
|
||||
|
||||
async getDocDiff(
|
||||
_docId: string,
|
||||
_state?: Uint8Array
|
||||
): Promise<DocDiff | null> {
|
||||
return null;
|
||||
}
|
||||
|
||||
async pushDocUpdate(_update: DocUpdate): Promise<DocClock> {
|
||||
this.pushCount++;
|
||||
const error = new Error('No permission to update doc');
|
||||
error.name = 'DOC_ACTION_DENIED';
|
||||
throw error;
|
||||
}
|
||||
|
||||
async getDocTimestamp(_docId: string): Promise<DocClock | null> {
|
||||
return null;
|
||||
}
|
||||
|
||||
async getDocTimestamps(): Promise<DocClocks> {
|
||||
return {};
|
||||
}
|
||||
|
||||
async deleteDoc(_docId: string): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
subscribeDocUpdate(_callback: (update: DocRecord, origin?: string) => void) {
|
||||
return () => {};
|
||||
}
|
||||
}
|
||||
|
||||
class PermissionDeniedConnection extends DummyConnection {
|
||||
waitCount = 0;
|
||||
|
||||
override async waitForConnected(_signal?: AbortSignal): Promise<void> {
|
||||
this.waitCount++;
|
||||
const error = new Error('No permission to access space');
|
||||
error.name = 'SPACE_ACCESS_DENIED';
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
class PermissionDeniedConnectionDocStorage extends PermissionDeniedRemoteDocStorage {
|
||||
override readonly connection = new PermissionDeniedConnection();
|
||||
}
|
||||
|
||||
class TrackingIndexerStorage extends IndexerStorageBase {
|
||||
override readonly connection = new DummyConnection();
|
||||
override readonly isReadonly = false;
|
||||
@@ -425,6 +484,201 @@ test('blob', async () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('doc sync peer stops retrying a doc when remote denies permission', async () => {
|
||||
const local = new IndexedDBDocStorage({
|
||||
id: 'ws-denied',
|
||||
flavour: 'local-denied',
|
||||
type: 'workspace',
|
||||
});
|
||||
const syncMetadata = new IndexedDBDocSyncStorage({
|
||||
id: 'ws-denied',
|
||||
flavour: 'local-denied',
|
||||
type: 'workspace',
|
||||
});
|
||||
const remote = new PermissionDeniedRemoteDocStorage('ws-denied');
|
||||
const peer = new DocSyncPeer('remote-denied', local, syncMetadata, remote);
|
||||
const abort = new AbortController();
|
||||
|
||||
local.connection.connect();
|
||||
syncMetadata.connection.connect();
|
||||
await local.connection.waitForConnected();
|
||||
await syncMetadata.connection.waitForConnected();
|
||||
|
||||
const doc = new YDoc();
|
||||
doc.getMap('test').set('hello', 'world');
|
||||
await local.pushDocUpdate({
|
||||
docId: 'doc-denied',
|
||||
bin: encodeStateAsUpdate(doc),
|
||||
});
|
||||
|
||||
try {
|
||||
void peer.mainLoop(abort.signal);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(remote.pushCount).toBe(1);
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
let state:
|
||||
| {
|
||||
syncing: boolean;
|
||||
synced: boolean;
|
||||
retrying: boolean;
|
||||
errorMessage: string | null;
|
||||
}
|
||||
| undefined;
|
||||
const dispose = peer.docState$('doc-denied').subscribe(next => {
|
||||
state = next;
|
||||
});
|
||||
dispose.unsubscribe();
|
||||
|
||||
expect(state).toMatchObject({
|
||||
syncing: false,
|
||||
synced: false,
|
||||
retrying: false,
|
||||
errorMessage: expect.stringContaining('No permission'),
|
||||
});
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
let state:
|
||||
| {
|
||||
synced: boolean;
|
||||
errorMessage: string | null;
|
||||
}
|
||||
| undefined;
|
||||
const dispose = peer.peerState$.subscribe(next => {
|
||||
state = next;
|
||||
});
|
||||
dispose.unsubscribe();
|
||||
|
||||
expect(state).toMatchObject({
|
||||
synced: false,
|
||||
errorMessage: expect.stringContaining('No permission'),
|
||||
});
|
||||
});
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 1200));
|
||||
expect(remote.pushCount).toBe(1);
|
||||
} finally {
|
||||
abort.abort();
|
||||
local.connection.disconnect();
|
||||
syncMetadata.connection.disconnect();
|
||||
}
|
||||
});
|
||||
|
||||
test('doc sync peer stops retrying when remote connection denies permission', async () => {
|
||||
const local = new IndexedDBDocStorage({
|
||||
id: 'ws-connection-denied',
|
||||
flavour: 'local-connection-denied',
|
||||
type: 'workspace',
|
||||
});
|
||||
const syncMetadata = new IndexedDBDocSyncStorage({
|
||||
id: 'ws-connection-denied',
|
||||
flavour: 'local-connection-denied',
|
||||
type: 'workspace',
|
||||
});
|
||||
const remote = new PermissionDeniedConnectionDocStorage(
|
||||
'ws-connection-denied'
|
||||
);
|
||||
const peer = new DocSyncPeer(
|
||||
'remote-connection-denied',
|
||||
local,
|
||||
syncMetadata,
|
||||
remote
|
||||
);
|
||||
const abort = new AbortController();
|
||||
|
||||
local.connection.connect();
|
||||
syncMetadata.connection.connect();
|
||||
await local.connection.waitForConnected();
|
||||
await syncMetadata.connection.waitForConnected();
|
||||
|
||||
try {
|
||||
void peer.mainLoop(abort.signal);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(remote.connection.waitCount).toBe(1);
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
let state:
|
||||
| {
|
||||
retrying: boolean;
|
||||
errorMessage: string | null;
|
||||
}
|
||||
| undefined;
|
||||
const dispose = peer.peerState$.subscribe(next => {
|
||||
state = next;
|
||||
});
|
||||
dispose.unsubscribe();
|
||||
|
||||
expect(state).toMatchObject({
|
||||
retrying: false,
|
||||
errorMessage: expect.stringContaining('No permission'),
|
||||
});
|
||||
});
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 1200));
|
||||
expect(remote.connection.waitCount).toBe(1);
|
||||
} finally {
|
||||
abort.abort();
|
||||
local.connection.disconnect();
|
||||
syncMetadata.connection.disconnect();
|
||||
}
|
||||
});
|
||||
|
||||
test('doc sync peer resolves on terminal permission error without abort signal', async () => {
|
||||
const local = new IndexedDBDocStorage({
|
||||
id: 'ws-connection-denied-no-signal',
|
||||
flavour: 'local-connection-denied-no-signal',
|
||||
type: 'workspace',
|
||||
});
|
||||
const syncMetadata = new IndexedDBDocSyncStorage({
|
||||
id: 'ws-connection-denied-no-signal',
|
||||
flavour: 'local-connection-denied-no-signal',
|
||||
type: 'workspace',
|
||||
});
|
||||
const remote = new PermissionDeniedConnectionDocStorage(
|
||||
'ws-connection-denied-no-signal'
|
||||
);
|
||||
const peer = new DocSyncPeer(
|
||||
'remote-connection-denied-no-signal',
|
||||
local,
|
||||
syncMetadata,
|
||||
remote
|
||||
);
|
||||
|
||||
local.connection.connect();
|
||||
syncMetadata.connection.connect();
|
||||
await local.connection.waitForConnected();
|
||||
await syncMetadata.connection.waitForConnected();
|
||||
|
||||
try {
|
||||
await expect(peer.mainLoop()).resolves.toBeUndefined();
|
||||
expect(remote.connection.waitCount).toBe(1);
|
||||
|
||||
let state:
|
||||
| {
|
||||
retrying: boolean;
|
||||
errorMessage: string | null;
|
||||
}
|
||||
| undefined;
|
||||
const dispose = peer.peerState$.subscribe(next => {
|
||||
state = next;
|
||||
});
|
||||
dispose.unsubscribe();
|
||||
|
||||
expect(state).toMatchObject({
|
||||
retrying: false,
|
||||
errorMessage: expect.stringContaining('No permission'),
|
||||
});
|
||||
} finally {
|
||||
local.connection.disconnect();
|
||||
syncMetadata.connection.disconnect();
|
||||
}
|
||||
});
|
||||
|
||||
test('indexer defers indexed clock persistence until a refresh happens on delayed refresh storages', async () => {
|
||||
const calls: string[] = [];
|
||||
const docsInRootDoc = new Map([['doc1', { title: 'Doc 1' }]]);
|
||||
|
||||
Reference in new issue
Block a user