Skip to content

host: an outside string that arrives ill-formed reaches visible text (peer name header, room-share notice) #1937

Closed

Description

@HugoXK

Implementation: FlyBot (ours) — PR follows within 24h. Do not pick up without a pickup-ok comment on this issue.

Written-by: FlyBot Lead
Claimed-by: FlyBot Lead
Claim-Id: ef4507e7308a459da2dac2d64b7b6e5a
Branch: dev/2-p290-clean-name-well-formed
Base: 4d851e8
At: 2026-10-31T13:48:52Z

Implementation: FlyBot (ours) — PR follows within 24h. Do not pick up without a pickup-ok comment.

Written-by: FlyBot Lead

A peer's or a room-share sender's string can arrive already ill-formed (a JSON escape like \ud83d is valid JSON and produces an unpaired surrogate). Every existing boundary fix (#1907, #1912, #1916, #1925, #1928, #1930, #1931) made our cuts land on whole code points; none of them heals an input that was broken to begin with. Two entry points then carry that broken string into text a human or a Bot reads.

Reproduction (live main 4d851e832084bdbf1fb8f8a2b1b78fce49bc4056)

Site 1 — a peer name in the chat header. core/src/host/delivery.js:38-41 cleanName strips control characters, brackets and quotes, trims, then cut(..., 60); it never normalizes well-formedness.

JSON.parse('{"name":"Ann\\ud83dX"}').name        // "Ann\ud83dX"  — legal JSON
cleanName('Ann\ud83dX')                          // "Ann\ud83dX", isWellFormed() === false
fenceUntrusted('Ann\ud83dX', 'hi')               // '<untrusted-message from="Ann\ud83dX">…', isWellFormed() === false

Reachable: remote-chat.js:293 shownName = cleanName(...) → :298 the message body line [Message from ${shownName} (${handle})] and :299 senderName; delivery.js:176 fromName (untrusted branch) → :193 senderName and headerFor(...)'s [Message from ${fromName}] first line. Those are the lines the owner reads in the chat, so an ill-formed name renders as Ann\ufffdX.

Site 2 — a room-share notice. core/src/shared/room-share.js:225-253 parseRoomShare returns the parsed JSON as-is; no field is normalized.

const body = { v: 1, op: 'join', roomId: 'r1', notice: 'Ann\ud83dX' }   // JSON-escape the lone surrogate
parseRoomShare(`${ROOM_SHARE_MARK}\n${JSON.stringify(body)}`).notice   // "Ann\ud83dX", isWellFormed() === false

The guest stores it in room.notice, and core/src/host/groups.js:350 standingText puts room.notice verbatim into the bot-room system prompt block, so the ill-formed string reaches the model's prompt (and the room row rendering). Today core/src has 0 occurrences of isWellFormed.

Expected

At each boundary where an outside string becomes text we own, normalize well-formedness once (String.prototype.toWellFormed(), Node 20+): an unpaired surrogate becomes U+FFFD. Then the existing cleaning and cutting run as they do now — no length, no boundary rule and no path changes. Output headers, senderName values, room.notice and every rendered line are always well-formed, and a UTF-8 round trip of a header is byte-identical.

Scope

  • core/src/host/delivery.js cleanName — one .toWellFormed() at the head of the cleaning chain.
  • core/src/shared/room-share.js parseRoomShare — normalize the string fields this envelope carries (notice, roster name/ownerName, history who/text) once, after the parse.
  • Tests: core/test/fence.test.js (a peer name with a lone surrogate → well-formed cleanName, well-formed fenceUntrusted first line, UTF-8 round trip equal, senderName clean) and the room-share suite (a parseRoomShareed notice with a lone surrogate → well-formed, no U+FFFD on the wire path).
  • Mirrors under patches/ds-bot/files/ regenerated; changelog fragment.

Out of scope: making the transport reject such a payload (another team's side of the wire), and healing values that never leave the process.

Dedupe

Open issues matching surrogate / well-formed / toWellFormed / U+FFFD: 0. The closed ones (#1907, #1912, #1916, #1925, #1928, #1930, #1931) all fixed cut points; this is the input side of the same class and none of them normalized an already-broken input. #1930 touched the fence's name cut, not the name's well-formedness.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions