Skip to content

Commit 8d6939c

Browse files
committed
fix(files): recheck access after content-room admission
1 parent 75544e3 commit 8d6939c

2 files changed

Lines changed: 84 additions & 15 deletions

File tree

apps/realtime/src/handlers/file-doc.test.ts

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1308,6 +1308,73 @@ describe('setupWorkspaceFileDocHandlers', () => {
13081308
}
13091309
)
13101310

1311+
it.each(['revoked', 'expired', 'unchanged'] as const)(
1312+
'checks %s access after an asynchronous content-room join',
1313+
async (access) => {
1314+
mockFetchFileDocSeed.mockResolvedValue(seedResult('# Private', 'doc-private'))
1315+
const { io } = createIo()
1316+
const memberships = new Set<string>()
1317+
let finishSubscription!: () => void
1318+
const subscription = new Promise<void>((resolve) => {
1319+
finishSubscription = resolve
1320+
})
1321+
const pending = setup('socket-content-subscription-access', io, {
1322+
join: vi.fn((name: string) => {
1323+
if (name === ROOM_NAME)
1324+
return subscription.then(() => {
1325+
memberships.add(name)
1326+
})
1327+
memberships.add(name)
1328+
}),
1329+
leave: vi.fn((name: string) => memberships.delete(name)),
1330+
})
1331+
const clock = vi.spyOn(Date, 'now')
1332+
const joining = pending.handlers[FILE_DOC_EVENTS.JOIN]({ fileId: 'file-1', clientId: 1 })
1333+
try {
1334+
await vi.waitFor(() => expect(pending.socket.join).toHaveBeenCalledWith(ROOM_NAME))
1335+
expect(joinSuccessFileId(pending.socket)).toBeUndefined()
1336+
if (access === 'revoked') {
1337+
commitRoomPermission(
1338+
'user-1',
1339+
{ type: ROOM_TYPES.WORKSPACE_FILE_DOC, id: 'file-1' },
1340+
'read',
1341+
beginRoomPermissionRead()
1342+
)
1343+
} else if (access === 'expired') {
1344+
clock.mockReturnValue(Date.now() + ROLE_REVALIDATION_TTL_MS + 1)
1345+
}
1346+
finishSubscription()
1347+
await joining
1348+
expect(memberships.has(fileDocAdmissionRoom('file-1'))).toBe(false)
1349+
expect(memberships.has(ROOM_NAME)).toBe(access === 'unchanged')
1350+
if (access === 'unchanged') {
1351+
expect(joinSuccessFileId(pending.socket)).toBe('file-1')
1352+
} else {
1353+
expect(joinSuccessFileId(pending.socket)).toBeUndefined()
1354+
expect(pending.socket.emit).toHaveBeenCalledWith(
1355+
FILE_DOC_EVENTS.JOIN_ERROR,
1356+
expect.objectContaining({
1357+
code: access === 'revoked' ? 'ACCESS_DENIED' : 'JOIN_FAILED',
1358+
retryable: access === 'expired',
1359+
})
1360+
)
1361+
expect(pending.socket.emit).not.toHaveBeenCalledWith(
1362+
FILE_DOC_EVENTS.MESSAGE,
1363+
expect.anything()
1364+
)
1365+
expect(pending.socket.emit).not.toHaveBeenCalledWith(
1366+
FILE_DOC_EVENTS.PRESENCE,
1367+
expect.anything()
1368+
)
1369+
}
1370+
} finally {
1371+
clock.mockRestore()
1372+
finishSubscription()
1373+
await joining
1374+
}
1375+
}
1376+
)
1377+
13111378
it('keeps a shared provisional subscription until the other provider finishes joining', async () => {
13121379
mockFetchFileDocSeed.mockResolvedValue(seedResult('# Shared', 'doc-shared'))
13131380
const { io } = createIo()

apps/realtime/src/handlers/file-doc.ts

Lines changed: 17 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1441,6 +1441,20 @@ export function setupWorkspaceFileDocHandlers(
14411441
!socket.disconnected &&
14421442
joinGeneration.get(socket.id) === generation &&
14431443
fileDocRooms.get(name) === entry
1444+
const canRegisterJoin = () => {
1445+
if (!isCurrentJoin()) return false
1446+
const permission = peekRoomPermission(userId, room)
1447+
if (satisfiesRoomMembership(permission ?? null, ROOM_TYPES.WORKSPACE_FILE_DOC)) return true
1448+
emitJoinError(
1449+
socket,
1450+
fileId,
1451+
clientId,
1452+
'File access changed while joining',
1453+
permission === undefined ? 'JOIN_FAILED' : 'ACCESS_DENIED',
1454+
permission === undefined
1455+
)
1456+
return false
1457+
}
14441458
try {
14451459
// A client is attached to a WHOLE document or to nothing. A room assembles itself from the
14461460
// shared stream and the server seed, and both land in the same Y.Doc that fans every update out
@@ -1495,22 +1509,10 @@ export function setupWorkspaceFileDocHandlers(
14951509
)
14961510
return
14971511
}
1498-
/** The generation read may wait; a revoked or expired access decision must not admit content. */
1499-
const membershipPermission = peekRoomPermission(userId, room)
1500-
if (!satisfiesRoomMembership(membershipPermission ?? null, ROOM_TYPES.WORKSPACE_FILE_DOC)) {
1501-
emitJoinError(
1502-
socket,
1503-
fileId,
1504-
clientId,
1505-
'File access changed while joining',
1506-
membershipPermission === undefined ? 'JOIN_FAILED' : 'ACCESS_DENIED',
1507-
membershipPermission === undefined
1508-
)
1509-
return
1510-
}
1512+
if (!canRegisterJoin()) return
15111513
await socket.join(name)
1512-
/** An asynchronous adapter join can be superseded by a leave, switch, or disconnect. */
1513-
if (!isCurrentJoin()) return
1514+
/** Adapter joins can wait; recheck access and liveness before ownership or synchronization. */
1515+
if (!canRegisterJoin()) return
15141516

15151517
// A client id must be owned by at most one user, or a peer could bind an active
15161518
// collaborator's id and pass the per-frame ownership check to spoof/clear its caret.

0 commit comments

Comments
 (0)