From 910d8e6b3c99f1db842211415c08cbc1a2df672b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 16 Oct 2026 14:37:24 +0000 Subject: [PATCH] Bind face-room human lines to the server-stamped sender Bot Social already puts an authenticated `from` snapshot on every face-room message. The FLYBOT-FACE-HUMAN `who` is client JSON inside untrusted text, so a peer could impersonate another member. Display the `from` label (or that handle's home) and ignore the claimed name. No Bot Social server change. Do not deploy. Co-authored-by: Danyang Chen --- changelog.d/118-face-human-who.security.md | 1 + docs/subsystems/plugin-runtime.md | 2 +- docs/subsystems/social.md | 2 +- plugins/social/src/face-rooms.js | 30 +++++++++++++++++++--- plugins/social/src/social.js | 2 +- plugins/social/test/face-rooms.test.js | 27 ++++++++++++++++++- plugins/social/test/social.test.js | 28 ++++++++++++++++++++ 7 files changed, 85 insertions(+), 7 deletions(-) create mode 100644 changelog.d/118-face-human-who.security.md diff --git a/changelog.d/118-face-human-who.security.md b/changelog.d/118-face-human-who.security.md new file mode 100644 index 000000000..65efe86ec --- /dev/null +++ b/changelog.d/118-face-human-who.security.md @@ -0,0 +1 @@ +A face-to-face human line no longer shows a name the sending client chose. The room labels the authenticated Bot Social `from` member (home / handle / display name). A spoofed `FLYBOT-FACE-HUMAN` `who` is ignored; old events without `from` fall back to that peer's home label, not another member. ([#118](https://github.com/ThinkFlowLab/FlyBot/pull/118)) diff --git a/docs/subsystems/plugin-runtime.md b/docs/subsystems/plugin-runtime.md index 94ea6f39d..0cc091298 100644 --- a/docs/subsystems/plugin-runtime.md +++ b/docs/subsystems/plugin-runtime.md @@ -68,7 +68,7 @@ FlyBot's plugin API surface on top of the patched core: host hooks, client slots ### Face-to-face owner lines -- `onFaceLine` receives every appended room line, including the owner's typed human lines. Channel `send` posts those through `postRoomMessage` (not `sessionController.prompt`) so they reach this hook immediately; the social plugin sends them through Bot Social as the owner's local room Bot with a `FLYBOT-FACE-HUMAN` envelope so every human in the room sees the text; incoming envelopes apply as `authorKind: 'human'` and the stored body is fenced (#98). Bot replies stay bot-authored. The envelope `who` is still taken from the JSON (a follow-up should bind the display name to the sending member record). Still not team mode. +- `onFaceLine` receives every appended room line, including the owner's typed human lines. Channel `send` posts those through `postRoomMessage` (not `sessionController.prompt`) so they reach this hook immediately; the social plugin sends them through Bot Social as the owner's local room Bot with a `FLYBOT-FACE-HUMAN` envelope so every human in the room sees the text; incoming envelopes apply as `authorKind: 'human'` and the stored body is fenced (#98). Bot replies stay bot-authored. The displayed sender is the Bot Social `from` snapshot (`trustedFaceWho`); the envelope `who` is ignored so a peer cannot impersonate another member. An event with no `from` falls back to the authenticated handle's home label, then Someone. Still not team mode. ### Host Bot look for classic-script plugins diff --git a/docs/subsystems/social.md b/docs/subsystems/social.md index dbdf24681..1f515461b 100644 --- a/docs/subsystems/social.md +++ b/docs/subsystems/social.md @@ -82,7 +82,7 @@ Everything in `plugins/social/` (entry `plugins/social/index.js` and any helpers ### Face-to-face two-instance rooms (#117) -- Friends' Bots polls `social/face-rooms` with `poll: true` about every 5s while the section is open, so an opened room, a new member, and the compact rooms card appear without a reload. While matching, the status chip is a short `10m` / `joining` label so the name and **Start** fit; after **Enter chat** / start the hosted `room.face.status` is `open`. After **Leave**, only **Past rooms** remains — no empty "Face-to-face rooms" header or Auto-send. Waiting tiles use the server `label` (`Chief · BoxB`). A snapshot member that is new on this side still gets a join line when the join event is before the floored `after` cursor. Owner-typed lines in the open room fan out over `onFaceLine`: Bot Social face messages are bot-authored, so FlyBot sends a `FLYBOT-FACE-HUMAN` envelope as the owner's local room Bot (the owner's click/type is consent; a `pending_approval` is decided in that same step) and the other instance applies it as `authorKind: 'human'`. Incoming peer/human bodies are stored fenced in the room log (#98). Join still does not grant the Bot to friends; Friends' Bots also refreshes `social/remote-bots` on its open timer. +- Friends' Bots polls `social/face-rooms` with `poll: true` about every 5s while the section is open, so an opened room, a new member, and the compact rooms card appear without a reload. While matching, the status chip is a short `10m` / `joining` label so the name and **Start** fit; after **Enter chat** / start the hosted `room.face.status` is `open`. After **Leave**, only **Past rooms** remains — no empty "Face-to-face rooms" header or Auto-send. Waiting tiles use the server `label` (`Chief · BoxB`). A snapshot member that is new on this side still gets a join line when the join event is before the floored `after` cursor. Owner-typed lines in the open room fan out over `onFaceLine`: Bot Social face messages are bot-authored, so FlyBot sends a `FLYBOT-FACE-HUMAN` envelope as the owner's local room Bot (the owner's click/type is consent; a `pending_approval` is decided in that same step) and the other instance applies it as `authorKind: 'human'`. The displayed sender is the server-stamped `from` member, never the envelope `who`. Incoming peer/human bodies are stored fenced in the room log (#98). Join still does not grant the Bot to friends; Friends' Bots also refreshes `social/remote-bots` on its open timer. ### Inbound shared groups diff --git a/plugins/social/src/face-rooms.js b/plugins/social/src/face-rooms.js index 9b19dafbe..bb4521dcd 100644 --- a/plugins/social/src/face-rooms.js +++ b/plugins/social/src/face-rooms.js @@ -221,6 +221,30 @@ export function humanFaceWho(line, fallback = 'Someone') { return cleanFaceName(fallback, 'Someone') } +// Display name for a face-room line: the Bot Social `from` snapshot is the +// authenticated sender. Never take `who` from the FLYBOT-FACE-HUMAN JSON (a +// peer can put another member's name there). Old events without `from` fall +// back to the handle's home label, then Someone — not the claimed name. +export function trustedFaceWho(from) { + if (!from || typeof from !== 'object') return 'Someone' + const label = cleanFaceName(from.label, '') + if (label) return label + const owner = cleanFaceName(from.owner_name || from.ownerName, '') + if (owner) { + const named = faceMemberLabel(from) + if (named && named !== 'Bot') return named + return owner + } + const handle = String(from.handle || from.id || '') + const parts = handle.replace(/^@+/, '').split('.').filter(Boolean) + const home = parts.length >= 2 && parts.at(-1)?.toLowerCase() === 'home' + ? parts[parts.length - 2] + : '' + const shown = cleanFaceName(home, '') + if (!shown) return 'Someone' + return shown.charAt(0).toUpperCase() + shown.slice(1) +} + export function faceRoomTitle(snapshot = {}, { code, room } = {}) { const owners = [] for (const member of snapshot.members ?? room?.participants ?? []) { @@ -406,14 +430,14 @@ export function renderFaceEvent(event) { const from = viewed.from || {} const body = viewed.untrustedText ?? '' const human = parseFaceHuman(body) - const label = human?.who || from.label || faceMemberLabel(from) + const who = trustedFaceWho(from) const text = human ? human.text : body return { kind: undefined, system: false, - who: label, + who, text, - fenced: fenceFaceText(label, text), + fenced: fenceFaceText(who, text), authorId: human ? (from.handle || 'human') : from.handle, authorKind: human ? 'human' : 'bot', remote: true, diff --git a/plugins/social/src/social.js b/plugins/social/src/social.js index 2d3cd70c7..257040ced 100644 --- a/plugins/social/src/social.js +++ b/plugins/social/src/social.js @@ -1982,7 +1982,7 @@ export function createSocial(service, config = {}, { client: given, api: givenAp } else if (line.human || line.authorKind === 'human') { const local = locals.some(bot => bot.handle && bot.handle === line.authorId) if (local) continue - const who = humanFaceWho(line, ownerDisplayName(snapshot, hosted)) + const who = line.who || 'Someone' flybot.rooms.applyFaceLine?.(room.id, { ...line, who, remote: true, authorKind: 'human' }) void flybot.rooms.postNotice?.(room.id, `${who}: ${line.text}`) } else { diff --git a/plugins/social/test/face-rooms.test.js b/plugins/social/test/face-rooms.test.js index abf578cc8..5d937a218 100644 --- a/plugins/social/test/face-rooms.test.js +++ b/plugins/social/test/face-rooms.test.js @@ -30,6 +30,7 @@ import { parseFaceHuman, faceOutbound, humanFaceWho, + trustedFaceWho, faceRoomTitle, FACE_HUMAN_MARK, } from '../src/face-rooms.js' @@ -138,10 +139,34 @@ test('owner-typed face-room lines encode as a human envelope', () => { untrusted_content: { text: packed }, }) assert.equal(rendered.authorKind, 'human') - assert.equal(rendered.who, 'BoxA') + assert.equal(rendered.who, "BoxA's Chief") assert.equal(rendered.text, 'hello from the owner') }) +test('a spoofed FLYBOT-FACE-HUMAN who cannot impersonate another room member', () => { + const packed = encodeFaceHuman({ who: 'Alice', text: 'I am Alice' }) + const spoofed = renderFaceEvent({ + event_id: 'fev_spoof', event_cursor: 10, type: 'room.message', + from: { handle: '@chief.bob.home', display_name: 'Chief', owner_name: 'Bob', label: "Bob's Chief" }, + untrusted_content: { text: packed }, + }) + assert.equal(spoofed.authorKind, 'human') + assert.equal(spoofed.who, "Bob's Chief") + assert.notEqual(spoofed.who, 'Alice') + assert.match(spoofed.fenced, /from="Bob's Chief"/) + assert.doesNotMatch(spoofed.fenced, /from="Alice"/) + assert.equal(spoofed.text, 'I am Alice') + const noFrom = renderFaceEvent({ + event_id: 'fev_old', event_cursor: 11, type: 'room.message', + untrusted_content: { text: packed }, + }) + assert.equal(noFrom.who, 'Someone') + assert.notEqual(noFrom.who, 'Alice') + assert.equal(trustedFaceWho({ handle: '@writer.alice.home' }), 'Alice') + assert.equal(trustedFaceWho({ label: 'Chief · BoxA', display_name: 'Chief', owner_name: 'BoxA' }), 'Chief · BoxA') + assert.equal(trustedFaceWho(undefined), 'Someone') +}) + test('peer room.message text is fenced and cannot rebuild a closing marker', () => { const event = viewFaceEvent({ event_id: 'fev_1', diff --git a/plugins/social/test/social.test.js b/plugins/social/test/social.test.js index f195b5390..9f73a042e 100644 --- a/plugins/social/test/social.test.js +++ b/plugins/social/test/social.test.js @@ -1509,6 +1509,34 @@ test('face-to-face: an owner-typed line is fanned out and shown as a human on th assert.match(String(human.text), /^ /hi I am Alice/.test(line.text || '')) + assert.ok(spoofed, 'the spoofed envelope still delivers its text') + assert.equal(spoofed.authorKind, 'human') + assert.match(String(spoofed.who), /Bob/) + assert.doesNotMatch(String(spoofed.who), /Alice/) +}) + test('face-to-face send pending_approval uses decide_approval', async (t) => { const { ctx, call, stop } = await team() t.after(stop)