From 1028aa9a50a78ef7c6a13beb6bb569da51e5d24f Mon Sep 17 00:00:00 2001 From: Day Dreamer <87640074+HugoXK@users.noreply.github.com> Date: Sat, 31 Oct 2026 21:59:38 +0800 Subject: [PATCH] room-share: repair a peer's notice, roster and history text on receive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit JSON carries an unpaired surrogate escape through as it came, so a peer's room notice, a roster entry's name and owner name, and a copied chat line reached the room and the guest's copy with a lone surrogate; the notice then went on to the bot-room standing prompt in that shape. parseRoomShare now makes those strings well formed after validation, one unit for one U+FFFD, so no length or code point bound moves. Docs-not-needed: docs/subsystems/room-share.md documents these fields as display data with their bounds; this change only makes a string that arrives broken well formed before it is passed on — no new field, bound, tool or path the page describes is added or changed. --- .../1937-peer-name-well-formed.fixed.md | 1 + core/src/host/delivery.js | 6 ++- core/src/shared/room-share.js | 21 ++++++++- core/test/fence.test.js | 27 +++++++++++ core/test/remote-room.test.js | 47 +++++++++++++++++++ patches/ds-bot/0005-social-seams.patch | 8 ++-- patches/ds-bot/files/src/shared/room-share.js | 21 ++++++++- patches/ds-bot/files/test/fence.test.js | 27 +++++++++++ patches/ds-bot/files/test/remote-room.test.js | 47 +++++++++++++++++++ 9 files changed, 198 insertions(+), 7 deletions(-) create mode 100644 changelog.d/1937-peer-name-well-formed.fixed.md diff --git a/changelog.d/1937-peer-name-well-formed.fixed.md b/changelog.d/1937-peer-name-well-formed.fixed.md new file mode 100644 index 000000000..045003623 --- /dev/null +++ b/changelog.d/1937-peer-name-well-formed.fixed.md @@ -0,0 +1 @@ +A peer name that arrives with half of a surrogate pair — a string JSON accepts but is not well formed — is now made well formed before it is cleaned, so the message header a person reads (`[Message from …]`), the `from="…"` attribute of an untrusted-message fence and the recorded `senderName` carry the replacement character instead of a lone half that would not survive a UTF-8 round trip. The same repair now happens when a shared room's envelope is parsed: the notice, a roster entry's name and owner name and a copied chat line are handed on well formed, so the room's standing prompt and the guest's copy of a line no longer carry a broken character. The 60-character name bound, the notice's 2,000-character bound and their code point boundaries are unchanged ([#1937](https://github.com/ThinkFlowLab/FlyBot/issues/1937)). diff --git a/core/src/host/delivery.js b/core/src/host/delivery.js index 0786b8ba5..89df594f8 100644 --- a/core/src/host/delivery.js +++ b/core/src/host/delivery.js @@ -34,9 +34,11 @@ export const unwrapUntrusted = (text) => { return match ? match[1] : value } // A name from outside the team, safe on one line of trusted text: no control characters, -// newlines, brackets or quotes, at most 60 characters. +// newlines, brackets or quotes, at most 60 characters. JSON admits a lone surrogate, so the +// input is made well formed first: a name that arrives with half a pair would otherwise reach +// the header a person reads, where it renders as U+FFFD and cannot survive a UTF-8 round trip. export const cleanName = (name, fallback = 'a remote Bot') => { - const text = cut(String(name ?? '').replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/g, ' ').replace(/[[\]"<>`]/g, '').replace(/\s+/g, ' ').trim(), 60).text + const text = cut(String(name ?? '').toWellFormed().replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/g, ' ').replace(/[[\]"<>`]/g, '').replace(/\s+/g, ' ').trim(), 60).text return text || fallback } export const fenceUntrusted = (name, text) => `\n${stripUntrusted(text ?? '')}\n` diff --git a/core/src/shared/room-share.js b/core/src/shared/room-share.js index 8ea2189c1..3b796a136 100644 --- a/core/src/shared/room-share.js +++ b/core/src/shared/room-share.js @@ -222,6 +222,25 @@ export function roomInviteUrl(handle, ticket, origin = 'https://social.botawaken return `${String(origin || '').replace(/\/+$/, '')}/r/${who}/${clean}` } +// #1937: the strings a peer sends in here are its own words — the room's notice, a roster entry's +// names, the chat lines a join copies — and JSON carries an unpaired surrogate escape through as it +// came, so JSON.parse hands back a string that is not well formed and the notice reaches the room's +// standing prompt in that shape. Repair the fields this parser passes on, in place: one unit becomes +// one U+FFFD, so no bound, cut or path around them moves. +function wellFormedFields(entry, keys) { + if (!entry || typeof entry !== 'object') return + for (const key of keys) { + if (typeof entry[key] === 'string') entry[key] = entry[key].toWellFormed() + } +} + +function wellFormedShare(data) { + wellFormedFields(data, ['notice']) + if (Array.isArray(data.roster)) data.roster.forEach(entry => wellFormedFields(entry, ['name', 'ownerName'])) + if (Array.isArray(data.history)) data.history.forEach(line => wellFormedFields(line, ['who', 'text'])) + return data +} + export function parseRoomShare(text) { const raw = String(text ?? '') const at = raw.indexOf(ROOM_SHARE_MARK) @@ -246,7 +265,7 @@ export function parseRoomShare(text) { if (data.op === 'ask') return TICKET_SHAPE.test(String(data.ticket ?? '')) ? data : undefined if (data.op === 'invite') return TICKET_SHAPE.test(String(data.ticket ?? '')) && data.roomId ? data : undefined if (!data.roomId) return undefined - return data + return wellFormedShare(data) } catch { return undefined } diff --git a/core/test/fence.test.js b/core/test/fence.test.js index f0b3e17d3..e950e7fb4 100644 --- a/core/test/fence.test.js +++ b/core/test/fence.test.js @@ -48,3 +48,30 @@ test('a peer name at the 60 bound keeps the whole character (#P282b)', () => { assert.ok(fenced.isWellFormed(), 'the fence header is not well formed') assert.ok(fenced.startsWith(``)) }) + +// A name from outside arrives through JSON, where "\ud83d" is a legal string that is not well +// formed: the lone half used to travel through cleanName into the header a person reads. +test('a peer name that arrives with a lone surrogate is made well formed', () => { + const broken = 'Ann\ud83dX' + assert.equal(broken.isWellFormed(), false, 'the fixture is the broken input') + const name = cleanName(broken) + assert.equal(name.isWellFormed(), true, `not well formed: ${JSON.stringify(name)}`) + assert.equal(name, 'Ann\uFFFDX', 'the lone half becomes the replacement character, in place') + assert.ok(name.length <= 60) + assert.doesNotMatch(name, /\p{Cc}/u, 'no control character survives the clean') +}) + +test('the line a broken peer name reaches stays well formed on every byte', () => { + const broken = 'Ann\ud83dX' + const fenced = fenceUntrusted(broken, 'hello') + const header = fenced.split('\n')[0] + assert.equal(header.isWellFormed(), true, `not well formed: ${JSON.stringify(header)}`) + assert.equal(header, '') + assert.equal(Buffer.from(header, 'utf8').toString('utf8'), header, 'the header survives a UTF-8 round trip') + // The first line a message is shown with, and the source name beside it, both read from the + // same cleanName, so neither can carry the lone half. + const shown = `[Message from ${cleanName(broken)}]` + assert.equal(shown.isWellFormed(), true, `not well formed: ${JSON.stringify(shown)}`) + assert.equal(shown, '[Message from Ann\uFFFDX]') + assert.equal(Buffer.from(shown, 'utf8').toString('utf8'), shown) +}) diff --git a/core/test/remote-room.test.js b/core/test/remote-room.test.js index bb839bc3e..1a539b8d7 100644 --- a/core/test/remote-room.test.js +++ b/core/test/remote-room.test.js @@ -890,6 +890,53 @@ test('a room-share envelope clips peer names on a code point boundary (#1923)', } }) +// #1937: the envelope carries a peer's own words — the room's notice, a roster entry's name and its +// owner's name, and the chat lines a join copies. JSON accepts a lone surrogate escape, so a peer can +// put one in each of those strings, and JSON.parse hands them back in the same broken shape; the +// notice then reaches the room's standing prompt and a line reaches the guest's copy. The parser +// repairs what it hands on, in place: one unit in, one unit out, no bound moved. +test('a room-share envelope hands on well formed notice, roster and history text (#1937)', () => { + const notice = `${'a'.repeat(19)}\ud83d tail` + const name = 'Ann\ud83dX' + const ownerName = 'Owner\ud83dY' + const who = 'W\ud83dho' + const line = 'hello \ud83d world' + const packed = encodeRoomShare({ + op: 'join', roomId: 'war', name: 'War room', inviterName: 'Alice', + inviterHome: '@alice.home', inviterHandle: '@chief.alice.home', invitedHandle: '@coder.bob.home', seq: 6, + roster: [{ handle: '@ann.home', name, ownerName }], + notice, + history: [{ who, text: line, seq: 2 }], + }) + // The wire text is well formed already: JSON.stringify escapes an unpaired surrogate. The break + // happens on the receiving side, in JSON.parse — this test pins the string the parser hands on. + assert.equal(packed.isWellFormed(), true, 'the wire text is well formed') + assert.equal(packed.includes('\ud83d'), false, 'the lone surrogate travels as an escape, not as a bare unit') + const parsed = parseRoomShare(packed) + assert.equal(parsed.op, 'join') + const cases = [ + ['notice', parsed.notice, notice], + ['roster[0].name', parsed.roster[0].name, name], + ['roster[0].ownerName', parsed.roster[0].ownerName, ownerName], + ['history[0].who', parsed.history[0].who, who], + ['history[0].text', parsed.history[0].text, line], + ] + for (const [field, kept, sent] of cases) { + assert.equal(typeof kept, 'string', `${field} is in the envelope: ${JSON.stringify(parsed)}`) + assert.equal(kept.isWellFormed(), true, `${field} is not well formed: ${JSON.stringify(kept)}`) + assert.equal(kept, sent.replace(/\ud83d/g, '\uFFFD'), `${field} replaces the lone surrogate with U+FFFD and keeps everything else`) + assert.equal(kept.length, sent.length, `${field} keeps the length it arrived with: ${kept.length} vs ${sent.length}`) + } + // A receiving home re-sends what it read: a second pass changes nothing. + const again = parseRoomShare(encodeRoomShare(parsed)) + assert.equal(again.notice, notice.replace(/\ud83d/g, '\uFFFD')) + assert.equal(again.notice.isWellFormed(), true) + // The shortest shape of the same arrival: one unpaired half between two letters. + const repaired = parseRoomShare(encodeRoomShare({ op: 'join', roomId: 'war', seq: 8, notice: 'Ann\ud83dX' })) + assert.equal(repaired.notice, 'Ann\uFFFDX') + assert.equal(repaired.notice.isWellFormed(), true) +}) + // A join copies chat lines into the envelope it sends to another owner, and its own bound is a // code-unit one: a line whose 2,000th unit is half of a surrogate pair reached the guest as half of // a character. The copied line stops before the pair instead. diff --git a/patches/ds-bot/0005-social-seams.patch b/patches/ds-bot/0005-social-seams.patch index 2283c687a..90ce0fe62 100644 --- a/patches/ds-bot/0005-social-seams.patch +++ b/patches/ds-bot/0005-social-seams.patch @@ -985,7 +985,7 @@ diff --git a/src/host/channel.js b/src/host/channel.js diff --git a/src/host/delivery.js b/src/host/delivery.js --- a/src/host/delivery.js +++ b/src/host/delivery.js -@@ -2,6 +2,62 @@ +@@ -2,6 +2,64 @@ import { randomUUID } from 'node:crypto' import { createUserMessage } from '@deepseek-ai/dsh-llm' import { SOURCE_KIND } from './constants.js' @@ -1021,9 +1021,11 @@ diff --git a/src/host/delivery.js b/src/host/delivery.js + return match ? match[1] : value +} +// A name from outside the team, safe on one line of trusted text: no control characters, -+// newlines, brackets or quotes, at most 60 characters. ++// newlines, brackets or quotes, at most 60 characters. JSON admits a lone surrogate, so the ++// input is made well formed first: a name that arrives with half a pair would otherwise reach ++// the header a person reads, where it renders as U+FFFD and cannot survive a UTF-8 round trip. +export const cleanName = (name, fallback = 'a remote Bot') => { -+ const text = cut(String(name ?? '').replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/g, ' ').replace(/[[\]"<>`]/g, '').replace(/\s+/g, ' ').trim(), 60).text ++ const text = cut(String(name ?? '').toWellFormed().replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/g, ' ').replace(/[[\]"<>`]/g, '').replace(/\s+/g, ' ').trim(), 60).text + return text || fallback +} +export const fenceUntrusted = (name, text) => `\n${stripUntrusted(text ?? '')}\n` diff --git a/patches/ds-bot/files/src/shared/room-share.js b/patches/ds-bot/files/src/shared/room-share.js index 8ea2189c1..3b796a136 100644 --- a/patches/ds-bot/files/src/shared/room-share.js +++ b/patches/ds-bot/files/src/shared/room-share.js @@ -222,6 +222,25 @@ export function roomInviteUrl(handle, ticket, origin = 'https://social.botawaken return `${String(origin || '').replace(/\/+$/, '')}/r/${who}/${clean}` } +// #1937: the strings a peer sends in here are its own words — the room's notice, a roster entry's +// names, the chat lines a join copies — and JSON carries an unpaired surrogate escape through as it +// came, so JSON.parse hands back a string that is not well formed and the notice reaches the room's +// standing prompt in that shape. Repair the fields this parser passes on, in place: one unit becomes +// one U+FFFD, so no bound, cut or path around them moves. +function wellFormedFields(entry, keys) { + if (!entry || typeof entry !== 'object') return + for (const key of keys) { + if (typeof entry[key] === 'string') entry[key] = entry[key].toWellFormed() + } +} + +function wellFormedShare(data) { + wellFormedFields(data, ['notice']) + if (Array.isArray(data.roster)) data.roster.forEach(entry => wellFormedFields(entry, ['name', 'ownerName'])) + if (Array.isArray(data.history)) data.history.forEach(line => wellFormedFields(line, ['who', 'text'])) + return data +} + export function parseRoomShare(text) { const raw = String(text ?? '') const at = raw.indexOf(ROOM_SHARE_MARK) @@ -246,7 +265,7 @@ export function parseRoomShare(text) { if (data.op === 'ask') return TICKET_SHAPE.test(String(data.ticket ?? '')) ? data : undefined if (data.op === 'invite') return TICKET_SHAPE.test(String(data.ticket ?? '')) && data.roomId ? data : undefined if (!data.roomId) return undefined - return data + return wellFormedShare(data) } catch { return undefined } diff --git a/patches/ds-bot/files/test/fence.test.js b/patches/ds-bot/files/test/fence.test.js index f0b3e17d3..e950e7fb4 100644 --- a/patches/ds-bot/files/test/fence.test.js +++ b/patches/ds-bot/files/test/fence.test.js @@ -48,3 +48,30 @@ test('a peer name at the 60 bound keeps the whole character (#P282b)', () => { assert.ok(fenced.isWellFormed(), 'the fence header is not well formed') assert.ok(fenced.startsWith(``)) }) + +// A name from outside arrives through JSON, where "\ud83d" is a legal string that is not well +// formed: the lone half used to travel through cleanName into the header a person reads. +test('a peer name that arrives with a lone surrogate is made well formed', () => { + const broken = 'Ann\ud83dX' + assert.equal(broken.isWellFormed(), false, 'the fixture is the broken input') + const name = cleanName(broken) + assert.equal(name.isWellFormed(), true, `not well formed: ${JSON.stringify(name)}`) + assert.equal(name, 'Ann\uFFFDX', 'the lone half becomes the replacement character, in place') + assert.ok(name.length <= 60) + assert.doesNotMatch(name, /\p{Cc}/u, 'no control character survives the clean') +}) + +test('the line a broken peer name reaches stays well formed on every byte', () => { + const broken = 'Ann\ud83dX' + const fenced = fenceUntrusted(broken, 'hello') + const header = fenced.split('\n')[0] + assert.equal(header.isWellFormed(), true, `not well formed: ${JSON.stringify(header)}`) + assert.equal(header, '') + assert.equal(Buffer.from(header, 'utf8').toString('utf8'), header, 'the header survives a UTF-8 round trip') + // The first line a message is shown with, and the source name beside it, both read from the + // same cleanName, so neither can carry the lone half. + const shown = `[Message from ${cleanName(broken)}]` + assert.equal(shown.isWellFormed(), true, `not well formed: ${JSON.stringify(shown)}`) + assert.equal(shown, '[Message from Ann\uFFFDX]') + assert.equal(Buffer.from(shown, 'utf8').toString('utf8'), shown) +}) diff --git a/patches/ds-bot/files/test/remote-room.test.js b/patches/ds-bot/files/test/remote-room.test.js index bb839bc3e..1a539b8d7 100644 --- a/patches/ds-bot/files/test/remote-room.test.js +++ b/patches/ds-bot/files/test/remote-room.test.js @@ -890,6 +890,53 @@ test('a room-share envelope clips peer names on a code point boundary (#1923)', } }) +// #1937: the envelope carries a peer's own words — the room's notice, a roster entry's name and its +// owner's name, and the chat lines a join copies. JSON accepts a lone surrogate escape, so a peer can +// put one in each of those strings, and JSON.parse hands them back in the same broken shape; the +// notice then reaches the room's standing prompt and a line reaches the guest's copy. The parser +// repairs what it hands on, in place: one unit in, one unit out, no bound moved. +test('a room-share envelope hands on well formed notice, roster and history text (#1937)', () => { + const notice = `${'a'.repeat(19)}\ud83d tail` + const name = 'Ann\ud83dX' + const ownerName = 'Owner\ud83dY' + const who = 'W\ud83dho' + const line = 'hello \ud83d world' + const packed = encodeRoomShare({ + op: 'join', roomId: 'war', name: 'War room', inviterName: 'Alice', + inviterHome: '@alice.home', inviterHandle: '@chief.alice.home', invitedHandle: '@coder.bob.home', seq: 6, + roster: [{ handle: '@ann.home', name, ownerName }], + notice, + history: [{ who, text: line, seq: 2 }], + }) + // The wire text is well formed already: JSON.stringify escapes an unpaired surrogate. The break + // happens on the receiving side, in JSON.parse — this test pins the string the parser hands on. + assert.equal(packed.isWellFormed(), true, 'the wire text is well formed') + assert.equal(packed.includes('\ud83d'), false, 'the lone surrogate travels as an escape, not as a bare unit') + const parsed = parseRoomShare(packed) + assert.equal(parsed.op, 'join') + const cases = [ + ['notice', parsed.notice, notice], + ['roster[0].name', parsed.roster[0].name, name], + ['roster[0].ownerName', parsed.roster[0].ownerName, ownerName], + ['history[0].who', parsed.history[0].who, who], + ['history[0].text', parsed.history[0].text, line], + ] + for (const [field, kept, sent] of cases) { + assert.equal(typeof kept, 'string', `${field} is in the envelope: ${JSON.stringify(parsed)}`) + assert.equal(kept.isWellFormed(), true, `${field} is not well formed: ${JSON.stringify(kept)}`) + assert.equal(kept, sent.replace(/\ud83d/g, '\uFFFD'), `${field} replaces the lone surrogate with U+FFFD and keeps everything else`) + assert.equal(kept.length, sent.length, `${field} keeps the length it arrived with: ${kept.length} vs ${sent.length}`) + } + // A receiving home re-sends what it read: a second pass changes nothing. + const again = parseRoomShare(encodeRoomShare(parsed)) + assert.equal(again.notice, notice.replace(/\ud83d/g, '\uFFFD')) + assert.equal(again.notice.isWellFormed(), true) + // The shortest shape of the same arrival: one unpaired half between two letters. + const repaired = parseRoomShare(encodeRoomShare({ op: 'join', roomId: 'war', seq: 8, notice: 'Ann\ud83dX' })) + assert.equal(repaired.notice, 'Ann\uFFFDX') + assert.equal(repaired.notice.isWellFormed(), true) +}) + // A join copies chat lines into the envelope it sends to another owner, and its own bound is a // code-unit one: a line whose 2,000th unit is half of a surrogate pair reached the guest as half of // a character. The copied line stops before the pair instead.