Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/2652.security.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
A room request naming one room in `id` and another in `roomId` no longer mints, reads or withdraws the other member's link, nor returns another member's live round: on the invite and round-read endpoints the permission gate and the handler resolve the room the same way, and such a request is refused ([#2652](https://github.com/ThinkFlowLab/FlyBot/issues/2652)).
8 changes: 4 additions & 4 deletions core/src/host/authz.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
// Install order matters: right after member-auth, so rt.authzEnforced sees the same
// state.authSessions the sign-in surface writes, and before any module it gates.

import { currentParticipants, roomVisibleTo } from './room-membership.js'
import { currentParticipants, roomTargetOf, roomVisibleTo } from './room-membership.js'
import { hasAnyGrant } from './reach.js'

// Endpoints a request without an actor may still reach. auth-invite keeps the
Expand Down Expand Up @@ -385,17 +385,17 @@ export function install(rt) {
return deny('bot/forbidden', 'You do not own that room or one of its Bots')
}
if (ep === 'room-history' || ep === 'room-progress' || ep === 'room-post') {
const id = body?.id ?? body?.roomId
const id = roomTargetOf(body)
return roomMemberOf(actor, id) || roomOwnedBy(actor, id) ? ALLOW : deny('bot/forbidden', 'You are not a member of that room')
}
if (ep === 'room-invite') {
const id = body?.id ?? body?.roomId
const id = roomTargetOf(body)
const room = state.rooms?.[id]
const me = currentParticipants(room).find(p => p.kind === 'human' && p.id === actor.memberId)
return roomOwnedBy(actor, id) || roomMemberOf(actor, id) || me?.role === 'admin' ? ALLOW : deny('bot/forbidden', 'You are not a member of that room')
}
if (ep === 'room-invite-mint' || ep === 'room-invite-revoke' || ep === 'room-invite-seat') {
const id = body?.id ?? body?.roomId
const id = roomTargetOf(body)
if (roomOwnedBy(actor, id)) return ALLOW
const room = state.rooms?.[id]
const me = currentParticipants(room).find(p => p.kind === 'human' && p.id === actor.memberId)
Expand Down
18 changes: 11 additions & 7 deletions core/src/host/channel.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { createUserMessage } from '@deepseek-ai/dsh-llm'
import { isApprovalWaiting } from '../shared/approval-waiting.js'
import { questionReply } from './parts.js'
import { COLORS, EXTRA_COLORS, ROUTE } from './constants.js'
import { isFaceRoom } from './room-membership.js'
import { isFaceRoom, roomTargetOf } from './room-membership.js'
import { readQuietHours } from '../shared/quiet-hours.js'
import { readTurnBudget } from './turn-budget.js'
import { faceMentions } from './face-mentions.js'
Expand Down Expand Up @@ -359,8 +359,10 @@ export function install(rt) {
case 'usage':
await rt.load()
return ok(await rt.usageView({ timeZone: typeof body.timeZone === 'string' ? body.timeZone.slice(0, 64) : undefined }))
case 'room-progress':
return ok([...rt.roomProgress.entries()].filter(([, entry]) => entry.roomId === body.roomId).map(([callId, entry]) => ({ callId, replies: entry.replies, speaking: entry.speaking ?? null })))
case 'room-progress': {
const id = roomTargetOf(body)
return ok([...rt.roomProgress.entries()].filter(([, entry]) => entry.roomId === id).map(([callId, entry]) => ({ callId, replies: entry.replies, speaking: entry.speaking ?? null })))
}
case 'create-bot': {
await rt.bootstrap()
const bot = await rt.createBot({ name: body.name, role: body.role, color: body.color, instructions: body.instructions, brief: body.brief, avatar: body.avatar, model: body.model, effort: body.effort, workDir: body.workDir, ownerId: rt.authzEnforced() ? actor.memberId : undefined })
Expand Down Expand Up @@ -428,21 +430,23 @@ export function install(rt) {
}
// #876: the room's own link. Reading it is what the Members card shows (copy and the
// countdown); minting replaces the live ticket, withdrawing stops answering at once.
// The gate checks the room this body resolves to (#2652): the handler must resolve it the
// same way, or `{ id: <mine>, roomId: <theirs> }` passes on one room and writes the other.
case 'room-invite': {
await rt.load()
const roomId = String(body.roomId ?? body.id ?? '')
const roomId = String(roomTargetOf(body) ?? '')
const invite = rt.localInviteOf(roomId)
// #878: every open link of the room; `invite` stays the newest, for the card that shows one.
return ok({ invite: invite ?? null, invites: rt.localInvitesOf(roomId) })
}
case 'room-invite-mint': {
await rt.load()
return ok({ invite: await rt.mintRoomInvite(String(body.roomId ?? body.id ?? ''), actor) })
return ok({ invite: await rt.mintRoomInvite(String(roomTargetOf(body) ?? ''), actor) })
}
case 'room-invite-revoke': {
await rt.load()
const ticket = typeof body.ticket === 'string' && body.ticket ? body.ticket : undefined
return ok(await rt.revokeRoomInvite(String(body.roomId ?? body.id ?? ''), ticket))
return ok(await rt.revokeRoomInvite(String(roomTargetOf(body) ?? ''), ticket))
}
// The invitee's side: what the link is (Bot Social's record when there is one), before Join.
case 'room-invite-preview': {
Expand All @@ -459,7 +463,7 @@ export function install(rt) {
// that did not leave). Same read as the poll, with the retries reset.
case 'room-invite-seat': {
await rt.load()
const roomId = String(body.roomId ?? body.id ?? '')
const roomId = String(roomTargetOf(body) ?? '')
const seated = await rt.seatOpenClaims({ roomId, force: true })
return ok({ ...seated, invite: rt.localInviteOf(roomId) ?? null, invites: rt.localInvitesOf(roomId) })
}
Expand Down
7 changes: 7 additions & 0 deletions core/src/host/room-membership.js
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,13 @@ export function presentRoom(room, actor, state) {
}
}

// The room a request body names (#2652). Clients send `roomId` on the invite surface and `id`
// elsewhere, so both are accepted; the permission gate and the handler it gates must resolve them
// the same way, or a body naming two rooms passes on one and writes the other.
export function roomTargetOf(body) {
return body?.roomId ?? body?.id
}

// A signed-in human may see a room when they are a current human participant,
// they own an invited Bot in it, or (legacy / single-owner) every bot belongs to them.
export function roomVisibleTo(state, actor, roomId) {
Expand Down
8 changes: 8 additions & 0 deletions core/test/authz.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,14 @@ test('owner cannot touch another owner Bot or room', async (t) => {
assert.equal(rt.authorize(actorA, 'room-invite-ask', { link: 'https://social.botawaken.com/r/@chief.b.home/AbCdEfGhIjKlMnOpQrStUv' }).ok, true)
assert.equal(rt.authorize(null, 'room-invite-ask', {}).ok, false, 'a signed-in actor is still required')

// #2652: `id` and `roomId` resolve to one room, so a body naming two is judged on the room the
// handler acts on (`roomId`) and cannot pass on the actor's own.
for (const ep of ['room-invite', 'room-invite-mint', 'room-invite-revoke', 'room-invite-seat']) {
const gate = rt.authorize(actorA, ep, { id: 'ra', roomId: 'rb' })
assert.equal(gate.ok, false, ep)
assert.equal(gate.code, 'bot/forbidden', ep)
}

const mixed = rt.authorize(actorA, 'create-room', { members: ['a1', 'b1'] })
assert.equal(mixed.ok, false)
assert.equal(mixed.code, 'bot/forbidden')
Expand Down
70 changes: 70 additions & 0 deletions core/test/room-membership.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -619,3 +619,73 @@ test('#2612: a junk wire seq never lands in state.json and never empties the roo
assert.deepEqual(floors(stored).filter(value => typeof value !== 'number'), [], `every stored member floor is a local number, saw ${JSON.stringify(floors(stored))}`)
}
})

test('#2652: the invite gate and the invite handler act on the same room', async (t) => {
const { call, rt } = startChannel(t, TEAM)
const first = (await call('auth-invite', {})).value
const alice = (await call('auth-redeem', { token: first.token, displayName: 'Alice' })).value
const second = (await call('auth-invite', {}, alice.sessionToken)).value
const bob = (await call('auth-redeem', { token: second.token, displayName: 'Bob' })).value
rt.state.members[bob.memberId].designatedChiefId = 'chief-b'
rt.state.bots['chief-b'].ownerId = bob.memberId
rt.state.bots['spec-b'].ownerId = bob.memberId
// A Bot Social handle on each Chief, so a link mints without the network (as #852 does).
rt.state.bots['chief-a'].fields = { social: { member: { home: '@alice.home', handle: '@chief.a.home' } } }
rt.state.bots['chief-b'].fields = { social: { member: { home: '@bob.home', handle: '@chief.b.home' } } }
rt.state.rooms.ra = { id: 'ra', name: 'RA', members: ['chief-a'], admin: 'chief-a', named: true, log: [], seq: 0, seen: {} }
rt.state.rooms.rb = { id: 'rb', name: 'RB', members: ['chief-b'], admin: 'chief-b', named: true, log: [], seq: 0, seen: {} }
await rt.save()

assert.equal((await call('room-invite-mint', { roomId: 'ra' }, alice.sessionToken)).ok, true)
const bobLink = (await call('room-invite-mint', { roomId: 'rb' }, bob.sessionToken)).value.invite
assert.equal(typeof bobLink.ticket, 'string', "Bob's room starts with its own link")

// `id` names the room the actor may use and `roomId` names another member's room: the gate and
// the handler must look at one room, so every one of these is refused, and Bob's link survives.
for (const endpoint of ['room-invite', 'room-invite-mint', 'room-invite-revoke', 'room-invite-seat']) {
const answer = await call(endpoint, { id: 'ra', roomId: 'rb' }, alice.sessionToken)
assert.equal(answer.ok, false, endpoint)
assert.equal(answer.error.code, 'bot/forbidden', endpoint)
}
assert.deepEqual(rt.localInvitesOf('rb').map(invite => invite.ticket), [bobLink.ticket], "the other member's link is untouched")

// The single-field bodies the client sends keep working exactly as they did.
assert.equal((await call('room-invite', { id: 'ra' }, alice.sessionToken)).ok, true, 'id alone names her own room')
assert.equal((await call('room-invite', { roomId: 'rb' }, bob.sessionToken)).ok, true, 'roomId alone names his own room')
assert.equal((await call('room-invite-mint', { id: 'rb' }, alice.sessionToken)).ok, false, "id alone on another member's room is still refused")
assert.equal((await call('room-invite-mint', { roomId: 'rb' }, alice.sessionToken)).ok, false, "roomId alone on another member's room is still refused")
})

test('#2652: the round-read gate and the room-progress handler act on the same room', async (t) => {
const { call, rt } = startChannel(t, TEAM)
const first = (await call('auth-invite', {})).value
const alice = (await call('auth-redeem', { token: first.token, displayName: 'Alice' })).value
const second = (await call('auth-invite', {}, alice.sessionToken)).value
const bob = (await call('auth-redeem', { token: second.token, displayName: 'Bob' })).value
rt.state.members[bob.memberId].designatedChiefId = 'chief-b'
rt.state.bots['chief-b'].ownerId = bob.memberId
rt.state.rooms.ra = { id: 'ra', name: 'RA', members: ['chief-a'], admin: 'chief-a', named: true, log: [], seq: 0, seen: {} }
rt.state.rooms.rb = { id: 'rb', name: 'RB', members: ['chief-b'], admin: 'chief-b', named: true, log: [], seq: 0, seen: {} }
await rt.save()

// A live round in each room; a round's replies carry the human text of the room (groups.js:889).
rt.roomProgress.set('call-a', { roomId: 'ra', replies: [{ kind: 'human', text: 'ALICE-OWN' }], speaking: { botId: 'chief-a' } })
rt.roomProgress.set('call-b', { roomId: 'rb', replies: [{ kind: 'human', text: 'BOB-ONLY-SECRET' }], speaking: { botId: 'chief-b' } })

// `id` names the room the actor may read and `roomId` names another member's room: the gate and
// the handler must look at one room, so this is refused and Bob's round never leaves his room.
const crossed = await call('room-progress', { id: 'ra', roomId: 'rb' }, alice.sessionToken)
assert.equal(crossed.ok, false)
assert.equal(crossed.error.code, 'bot/forbidden')
assert.equal((await call('room-progress', { roomId: 'rb' }, alice.sessionToken)).ok, false, "roomId alone on another member's room is still refused")
assert.equal((await call('room-progress', { id: 'rb' }, alice.sessionToken)).ok, false, "id alone on another member's room is still refused")

// Her own room resolves by either field, and returns her own round and nothing else.
for (const body of [{ roomId: 'ra' }, { id: 'ra' }]) {
const own = await call('room-progress', body, alice.sessionToken)
assert.equal(own.ok, true, JSON.stringify(body))
assert.deepEqual(own.value.map(round => round.callId), ['call-a'], JSON.stringify(body))
}
// Bob still reads his own room.
assert.deepEqual((await call('room-progress', { roomId: 'rb' }, bob.sessionToken)).value.map(round => round.callId), ['call-b'])
})
26 changes: 18 additions & 8 deletions patches/ds-bot/0005-social-seams.patch
Original file line number Diff line number Diff line change
Expand Up @@ -274,7 +274,7 @@ diff --git a/src/host/channel.js b/src/host/channel.js
+import { isApprovalWaiting } from '../shared/approval-waiting.js'
import { questionReply } from './parts.js'
import { COLORS, EXTRA_COLORS, ROUTE } from './constants.js'
+import { isFaceRoom } from './room-membership.js'
+import { isFaceRoom, roomTargetOf } from './room-membership.js'
+import { readQuietHours } from '../shared/quiet-hours.js'
+import { readTurnBudget } from './turn-budget.js'
+import { faceMentions } from './face-mentions.js'
Expand Down Expand Up @@ -618,8 +618,16 @@ diff --git a/src/host/channel.js b/src/host/channel.js
case 'duplicate-bot':
await rt.load()
return ok(await rt.duplicateBot(body.id))
@@ -141,7 +141,7 @@ export function install(rt) {
return ok([...rt.roomProgress.entries()].filter(([, entry]) => entry.roomId === body.roomId).map(([callId, entry]) => ({ callId, replies: entry.replies, speaking: entry.speaking ?? null })))
@@ -137,11 +137,13 @@ export function install(rt) {
case 'usage':
await rt.load()
return ok(await rt.usageView({ timeZone: typeof body.timeZone === 'string' ? body.timeZone.slice(0, 64) : undefined }))
- case 'room-progress':
- return ok([...rt.roomProgress.entries()].filter(([, entry]) => entry.roomId === body.roomId).map(([callId, entry]) => ({ callId, replies: entry.replies, speaking: entry.speaking ?? null })))
+ case 'room-progress': {
+ const id = roomTargetOf(body)
+ return ok([...rt.roomProgress.entries()].filter(([, entry]) => entry.roomId === id).map(([callId, entry]) => ({ callId, replies: entry.replies, speaking: entry.speaking ?? null })))
+ }
case 'create-bot': {
await rt.bootstrap()
- const bot = await rt.createBot({ name: body.name, role: body.role, color: body.color, instructions: body.instructions, brief: body.brief, avatar: body.avatar, model: body.model, effort: body.effort, workDir: body.workDir })
Expand Down Expand Up @@ -655,7 +663,7 @@ diff --git a/src/host/channel.js b/src/host/channel.js
return ok(room)
}
case 'update-room': {
@@ -171,12 +171,70 @@ export function install(rt) {
@@ -171,12 +171,72 @@ export function install(rt) {
notice: body.notice,
mode: body.mode,
admin: body.admin,
Expand Down Expand Up @@ -691,21 +699,23 @@ diff --git a/src/host/channel.js b/src/host/channel.js
+ }
+ // #876: the room's own link. Reading it is what the Members card shows (copy and the
+ // countdown); minting replaces the live ticket, withdrawing stops answering at once.
+ // The gate checks the room this body resolves to (#2652): the handler must resolve it the
+ // same way, or `{ id: <mine>, roomId: <theirs> }` passes on one room and writes the other.
+ case 'room-invite': {
+ await rt.load()
+ const roomId = String(body.roomId ?? body.id ?? '')
+ const roomId = String(roomTargetOf(body) ?? '')
+ const invite = rt.localInviteOf(roomId)
+ // #878: every open link of the room; `invite` stays the newest, for the card that shows one.
+ return ok({ invite: invite ?? null, invites: rt.localInvitesOf(roomId) })
+ }
+ case 'room-invite-mint': {
+ await rt.load()
+ return ok({ invite: await rt.mintRoomInvite(String(body.roomId ?? body.id ?? ''), actor) })
+ return ok({ invite: await rt.mintRoomInvite(String(roomTargetOf(body) ?? ''), actor) })
+ }
+ case 'room-invite-revoke': {
+ await rt.load()
+ const ticket = typeof body.ticket === 'string' && body.ticket ? body.ticket : undefined
+ return ok(await rt.revokeRoomInvite(String(body.roomId ?? body.id ?? ''), ticket))
+ return ok(await rt.revokeRoomInvite(String(roomTargetOf(body) ?? ''), ticket))
+ }
+ // The invitee's side: what the link is (Bot Social's record when there is one), before Join.
+ case 'room-invite-preview': {
Expand All @@ -722,7 +732,7 @@ diff --git a/src/host/channel.js b/src/host/channel.js
+ // that did not leave). Same read as the poll, with the retries reset.
+ case 'room-invite-seat': {
+ await rt.load()
+ const roomId = String(body.roomId ?? body.id ?? '')
+ const roomId = String(roomTargetOf(body) ?? '')
+ const seated = await rt.seatOpenClaims({ roomId, force: true })
+ return ok({ ...seated, invite: rt.localInviteOf(roomId) ?? null, invites: rt.localInvitesOf(roomId) })
}
Expand Down
8 changes: 4 additions & 4 deletions patches/ds-bot/files/src/host/authz.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
// Install order matters: right after member-auth, so rt.authzEnforced sees the same
// state.authSessions the sign-in surface writes, and before any module it gates.

import { currentParticipants, roomVisibleTo } from './room-membership.js'
import { currentParticipants, roomTargetOf, roomVisibleTo } from './room-membership.js'
import { hasAnyGrant } from './reach.js'

// Endpoints a request without an actor may still reach. auth-invite keeps the
Expand Down Expand Up @@ -385,17 +385,17 @@ export function install(rt) {
return deny('bot/forbidden', 'You do not own that room or one of its Bots')
}
if (ep === 'room-history' || ep === 'room-progress' || ep === 'room-post') {
const id = body?.id ?? body?.roomId
const id = roomTargetOf(body)
return roomMemberOf(actor, id) || roomOwnedBy(actor, id) ? ALLOW : deny('bot/forbidden', 'You are not a member of that room')
}
if (ep === 'room-invite') {
const id = body?.id ?? body?.roomId
const id = roomTargetOf(body)
const room = state.rooms?.[id]
const me = currentParticipants(room).find(p => p.kind === 'human' && p.id === actor.memberId)
return roomOwnedBy(actor, id) || roomMemberOf(actor, id) || me?.role === 'admin' ? ALLOW : deny('bot/forbidden', 'You are not a member of that room')
}
if (ep === 'room-invite-mint' || ep === 'room-invite-revoke' || ep === 'room-invite-seat') {
const id = body?.id ?? body?.roomId
const id = roomTargetOf(body)
if (roomOwnedBy(actor, id)) return ALLOW
const room = state.rooms?.[id]
const me = currentParticipants(room).find(p => p.kind === 'human' && p.id === actor.memberId)
Expand Down
7 changes: 7 additions & 0 deletions patches/ds-bot/files/src/host/room-membership.js
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,13 @@ export function presentRoom(room, actor, state) {
}
}

// The room a request body names (#2652). Clients send `roomId` on the invite surface and `id`
// elsewhere, so both are accepted; the permission gate and the handler it gates must resolve them
// the same way, or a body naming two rooms passes on one and writes the other.
export function roomTargetOf(body) {
return body?.roomId ?? body?.id
}

// A signed-in human may see a room when they are a current human participant,
// they own an invited Bot in it, or (legacy / single-owner) every bot belongs to them.
export function roomVisibleTo(state, actor, roomId) {
Expand Down
Loading
Loading