From 52f6742ea91c61f4e4671753a840f4cc9ed1a689 Mon Sep 17 00:00:00 2001 From: Danyang Chen Date: Sun, 8 Nov 2026 06:45:40 +0800 Subject: [PATCH] fix(client): escape a friend's avatar URL in the mentions stylesheet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A friend's Bot publishes its own look, and mentionCss pasted avatar.image into url("…") in the page-wide stylesheet; the three entry points only rejected whitespace and <> , so a value with a quote, a paren and a brace closed the declaration and the rule and added page-wide CSS. safeLookImage()/cssImageUrl() in src/shared/look-image.js keep a plain https URL or a base64 raster image and drop anything that could leave url("…"); the sink draws the colour/mask look instead, and cleanRemoteLook, applyPublishedLook and the plugin's lookFromServer run the same test. The plugin copy (png/jpeg/webp) is kept in step with the core one by a test. Fixes #2646 --- changelog.d/2646.fixed.md | 1 + core/client.js | 26 ++++++- core/src/client/mention-style.js | 8 ++- core/src/host/hooks.js | 5 +- core/src/host/remote-chat.js | 52 ++++++++------ core/src/shared/look-image.js | 27 ++++++++ core/test/mention-style.test.js | 69 +++++++++++++++++++ docs/subsystems/core-client.md | 2 +- docs/subsystems/core-host.md | 1 + docs/subsystems/plugin-runtime.md | 2 +- docs/subsystems/social.md | 2 +- patches/ds-bot/0008-client-bundle.patch | 28 +++++++- .../ds-bot/files/src/client/mention-style.js | 8 ++- patches/ds-bot/files/src/host/hooks.js | 5 +- patches/ds-bot/files/src/host/remote-chat.js | 52 ++++++++------ patches/ds-bot/files/src/shared/look-image.js | 27 ++++++++ .../ds-bot/files/test/mention-style.test.js | 69 +++++++++++++++++++ plugins/social/src/look.js | 22 +++++- plugins/social/test/look.test.js | 56 +++++++++++++++ 19 files changed, 404 insertions(+), 58 deletions(-) create mode 100644 changelog.d/2646.fixed.md create mode 100644 core/src/shared/look-image.js create mode 100644 core/test/mention-style.test.js create mode 100644 patches/ds-bot/files/src/shared/look-image.js create mode 100644 patches/ds-bot/files/test/mention-style.test.js create mode 100644 plugins/social/test/look.test.js diff --git a/changelog.d/2646.fixed.md b/changelog.d/2646.fixed.md new file mode 100644 index 000000000..49ddb6a9f --- /dev/null +++ b/changelog.d/2646.fixed.md @@ -0,0 +1 @@ +A friend's Bot can no longer put its own CSS on your page: the image URL in its published look is checked where it enters and again in the page-wide mention stylesheet, so a value carrying a quote, a parenthesis, a backslash or a control character is dropped and the Bot is drawn with its colour and mask instead of its photo ([#2646](https://github.com/ThinkFlowLab/FlyBot/issues/2646)). diff --git a/core/client.js b/core/client.js index 37a06215a..aa28327e2 100644 --- a/core/client.js +++ b/core/client.js @@ -4935,6 +4935,29 @@ function registerTheme(id, theme) { } var allThemes = () => ({ ...THEMES, ...customThemes }); +// src/shared/look-image.js +var LOOK_IMAGE_MAX = 65536; +var DATA_IMAGE = /^data:image\/(png|jpeg|webp|gif);base64,[A-Za-z0-9+/]+=*$/i; +var HTTP = /^https?:\/\//i; +var UNSAFE = /["'()\\\u0000-\u001f\u007f\s<>]/; +function safeLookImage(value, max = LOOK_IMAGE_MAX) { + if (typeof value !== "string") return null; + const text2 = value.trim(); + if (text2.length === 0 || text2.length > max || UNSAFE.test(text2)) return null; + if (DATA_IMAGE.test(text2)) return text2; + if (!HTTP.test(text2)) return null; + try { + new URL(text2); + } catch { + return null; + } + return text2; +} +function cssImageUrl(value) { + const image = safeLookImage(value); + return image === null ? null : `url("${image}")`; +} + // src/client/mention-style.js function chipKey(bot) { if (/\s/.test(bot?.name)) return null; @@ -4956,7 +4979,8 @@ function mentionCss(bots) { const chip = key !== null && owner.get(key) === bot ? `[class*="_userRow"] [data-ref-chip="file"][title="@${String(bot.name).replace(/["\\]/g, "\\$&")}" i]` : ""; const look = bot?.remote ? lookAvoidingLocals(bot) : lookOf(bot); const marks = chip ? `${anchor}::before,${chip}::before` : `${anchor}::before`; - const mark = look.image ? `${marks}{background:center/cover url("${look.image}");-webkit-mask:none;mask:none;border-radius:50%}` : `${marks}{background:${paintCss(look.color)};-webkit-mask-image:${maskOf(look.shape)};mask-image:${maskOf(look.shape)}}`; + const image = cssImageUrl(look.image); + const mark = image ? `${marks}{background:center/cover ${image};-webkit-mask:none;mask:none;border-radius:50%}` : `${marks}{background:${paintCss(look.color)};-webkit-mask-image:${maskOf(look.shape)};mask-image:${maskOf(look.shape)}}`; const chipBase = chip ? `${chip}{color:inherit;font-weight:600}${chip}>svg{display:none}${chip}::before{content:"";display:inline-block;flex:none;width:16px;height:16px;margin-right:3px;vertical-align:-3px;-webkit-mask:${DEFAULT_MASK} center/contain no-repeat;mask:${DEFAULT_MASK} center/contain no-repeat}` : ""; return `${anchor}{color:${inkText(look.color)}}${chipBase}${mark}`; }).join("\n"); diff --git a/core/src/client/mention-style.js b/core/src/client/mention-style.js index dd2e33655..336fc5e00 100644 --- a/core/src/client/mention-style.js +++ b/core/src/client/mention-style.js @@ -1,5 +1,6 @@ import { lookAvoidingLocals, lookOf, maskOf, DEFAULT_MASK } from './characters.js' import { inkText, paintCss } from './inks.js' +import { cssImageUrl } from '../shared/look-image.js' import { MENTION_ORIGIN } from './text.js' // One @Name file chip per display name. A local Bot owns the chip when a @@ -30,8 +31,11 @@ export function mentionCss(bots) { // sidebar has already stepped off (#1088). A local Bot still owns a shared @Name chip. const look = bot?.remote ? lookAvoidingLocals(bot) : lookOf(bot) const marks = chip ? `${anchor}::before,${chip}::before` : `${anchor}::before` - const mark = look.image - ? `${marks}{background:center/cover url("${look.image}");-webkit-mask:none;mask:none;border-radius:50%}` + // A friend's Bot publishes this URL, so the value is checked here as well as where it + // enters; one that could leave url("…") draws the colour/mask look instead (#2646). + const image = cssImageUrl(look.image) + const mark = image + ? `${marks}{background:center/cover ${image};-webkit-mask:none;mask:none;border-radius:50%}` : `${marks}{background:${paintCss(look.color)};-webkit-mask-image:${maskOf(look.shape)};mask-image:${maskOf(look.shape)}}` const chipBase = chip ? `${chip}{color:inherit;font-weight:600}${chip}>svg{display:none}${chip}::before{content:"";display:inline-block;flex:none;width:16px;height:16px;margin-right:3px;vertical-align:-3px;-webkit-mask:${DEFAULT_MASK} center/contain no-repeat;mask:${DEFAULT_MASK} center/contain no-repeat}` : '' return `${anchor}{color:${inkText(look.color)}}${chipBase}${mark}` diff --git a/core/src/host/hooks.js b/core/src/host/hooks.js index 8a1b886b8..072120ccb 100644 --- a/core/src/host/hooks.js +++ b/core/src/host/hooks.js @@ -13,6 +13,7 @@ import { createHash, randomUUID } from 'node:crypto' import { createUserMessage } from '@deepseek-ai/dsh-llm' import { isApprovalWaiting } from '../shared/approval-waiting.js' import { canRetry } from '../shared/held-retry.js' +import { safeLookImage } from '../shared/look-image.js' import { mentionSpans } from '../shared/mentions.js' import { isHomeLevelRemote, remoteMentionLabel } from '../shared/remote-label.js' import { cleanName, stripSessionChips } from './delivery.js' @@ -937,8 +938,8 @@ export function install(rt) { const avatar = {} if (typeof src.shape === 'string' && /^[a-z0-9][a-z0-9_-]{0,31}$/.test(src.shape)) avatar.shape = src.shape if (typeof src.image === 'string') { - const image = src.image.trim() - if (image.length <= 65536 && /^(https?:\/\/|data:image\/)/i.test(image) && !/[\s<>]/.test(image)) avatar.image = image + const image = safeLookImage(src.image) + if (image) avatar.image = image } if (typeof src.emoji === 'string') { const emoji = src.emoji.trim() diff --git a/core/src/host/remote-chat.js b/core/src/host/remote-chat.js index 84b01699f..a9dd1d853 100644 --- a/core/src/host/remote-chat.js +++ b/core/src/host/remote-chat.js @@ -15,6 +15,7 @@ import { createUserMessage, LlmAdapter } from '@deepseek-ai/dsh-llm' import { SOURCE_KIND } from './constants.js' import { cleanName, fenceUntrusted } from './delivery.js' import { isHomeLevelRemote, normRemoteHandle } from '../shared/remote-label.js' +import { safeLookImage } from '../shared/look-image.js' import { cut } from './text.js' export const REMOTE_PROVIDER = 'flybot-remote' @@ -24,6 +25,36 @@ const SENT_KEPT = 500 export const isRemoteChat = bot => typeof bot?.remote?.handle === 'string' const textOf = content => (Array.isArray(content) ? content.filter(block => block?.type === 'text').map(block => block.text).join('\n') : String(content ?? '')) +// A published look reaches the mention stylesheet, so its image is checked here as well as +// at that sink: a value that could leave url("…") is dropped and the Bot keeps no photo (#2646). +export function applyPublishedLook(bot, member) { + if (!bot || !member) return false + const at = typeof member.avatarUpdatedAt === 'string' ? member.avatarUpdatedAt : '' + if (at && bot.avatarUpdatedAt === at) return false + let changed = false + if (typeof member.color === 'string' && member.color && bot.color !== member.color) { + bot.color = member.color + changed = true + } + if (member.avatar && typeof member.avatar === 'object') { + const avatar = { ...member.avatar } + if (avatar.image !== undefined) { + const image = safeLookImage(avatar.image) + if (image === null) delete avatar.image + else avatar.image = image + } + if (JSON.stringify(bot.avatar) !== JSON.stringify(avatar)) { + bot.avatar = avatar + changed = true + } + } + if (at && bot.avatarUpdatedAt !== at) { + bot.avatarUpdatedAt = at + changed = true + } + return changed +} + // Sidebar / chat preview for a failed 1:1 send to a friend's Bot. Keep it one short // English line with a next step; never dump `Bot Social send_as_member: …`. export function notSentLine(error) { @@ -193,27 +224,6 @@ export function install(rt) { } let opening = Promise.resolve() - function applyPublishedLook(bot, member) { - if (!bot || !member) return false - const at = typeof member.avatarUpdatedAt === 'string' ? member.avatarUpdatedAt : '' - if (at && bot.avatarUpdatedAt === at) return false - let changed = false - if (typeof member.color === 'string' && member.color && bot.color !== member.color) { - bot.color = member.color - changed = true - } - if (member.avatar && typeof member.avatar === 'object') { - if (JSON.stringify(bot.avatar) !== JSON.stringify(member.avatar)) { - bot.avatar = { ...member.avatar } - changed = true - } - } - if (at && bot.avatarUpdatedAt !== at) { - bot.avatarUpdatedAt = at - changed = true - } - return changed - } // Creates the chat for a remote Bot (once), or refreshes its name, home label, and look. function open({ handle, name, home, homeName, plugin, color, avatar, avatarUpdatedAt }) { if (isHomeLevelRemote({ handle, home })) return Promise.resolve(undefined) diff --git a/core/src/shared/look-image.js b/core/src/shared/look-image.js new file mode 100644 index 000000000..ffbf47589 --- /dev/null +++ b/core/src/shared/look-image.js @@ -0,0 +1,27 @@ +// The image URL a Bot's look may carry. A friend's Bot publishes its own look, and the +// page-wide mentions stylesheet pastes `avatar.image` into `url("…")` (client/mention-style.js), +// so a value that can close the quote, the url() or the rule is dropped where it enters — +// hooks.js `cleanRemoteLook` and remote-chat.js `applyPublishedLook` — and again at that sink. +// A dropped image draws the colour/mask look instead. The social plugin carries the same test +// for its own threshold (no gif there); plugins/social/test/look.test.js keeps the two in step. +export const LOOK_IMAGE_MAX = 65536 +const DATA_IMAGE = /^data:image\/(png|jpeg|webp|gif);base64,[A-Za-z0-9+/]+=*$/i +const HTTP = /^https?:\/\//i +const UNSAFE = /["'()\\\u0000-\u001f\u007f\s<>]/ + +// The trimmed image URL, or null when it is not a plain https URL or a base64 raster image. +export function safeLookImage(value, max = LOOK_IMAGE_MAX) { + if (typeof value !== 'string') return null + const text = value.trim() + if (text.length === 0 || text.length > max || UNSAFE.test(text)) return null + if (DATA_IMAGE.test(text)) return text + if (!HTTP.test(text)) return null + try { new URL(text) } catch { return null } + return text +} + +// The same value, ready to sit inside a stylesheet as `url("…")`; null means draw the colour. +export function cssImageUrl(value) { + const image = safeLookImage(value) + return image === null ? null : `url("${image}")` +} diff --git a/core/test/mention-style.test.js b/core/test/mention-style.test.js new file mode 100644 index 000000000..5ee651d84 --- /dev/null +++ b/core/test/mention-style.test.js @@ -0,0 +1,69 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { mentionCss } from '../src/client/mention-style.js' +import { applyPublishedLook } from '../src/host/remote-chat.js' +import { cssImageUrl, safeLookImage } from '../src/shared/look-image.js' + +// The payload from #2646: a friend's Bot publishes a look whose image closes url("…"), +// closes the rule, and adds a page-wide `body{…}` rule to the mention stylesheet. +const PAYLOAD = `data:image/png;base64,AA");}body{background:url(https://evil.example/beacon)}.z{'` +const evil = { + id: 'remote:evil.home', + name: 'Evil', + remote: { handle: 'evil.home' }, + avatar: { image: PAYLOAD }, +} + +const urlQuotes = css => (css.match(/url\("/g) ?? []).length +const urlCloses = css => (css.match(/"\)/g) ?? []).length + +test('a payload image cannot leave url("…") in the page-wide mention stylesheet (#2646)', () => { + const css = mentionCss([evil]) + assert.equal(css.includes('body{'), false) + assert.equal(css.includes('evil.example'), false) + assert.equal(urlQuotes(css), urlCloses(css)) + // The sink drops the photo and draws the colour/mask look instead. + assert.match(css, /-webkit-mask-image:/) + assert.equal(css.includes(PAYLOAD), false) +}) + +test('a valid image still draws the photo in the mention stylesheet (#2646)', () => { + // A display name with a space owns no @Name chip, so the DEFAULT_MASK url() stays out of the line. + const friend = { id: 'remote:ok', name: 'Ok Friend', remote: { handle: 'ok.home' }, avatar: { image: 'https://ok.example/a.png' } } + const css = mentionCss([friend]) + assert.match(css, /background:center\/cover url\("https:\/\/ok\.example\/a\.png"\)/) + assert.equal(urlQuotes(css), 1) + assert.equal(urlCloses(css), 1) + // A local Bot's gif avatar is a stored form and keeps drawing (roster.js accepts it). + const local = { id: 'local', name: 'Local Bot', avatar: { image: 'data:image/gif;base64,R0lGODlhAQABAAAAACw=' } } + assert.match(mentionCss([local]), /url\("data:image\/gif;base64,R0lGODlhAQABAAAAACw="\)/) +}) + +test('a published look drops an unsafe image and keeps a plain one (#2646)', () => { + const dropped = {} + applyPublishedLook(dropped, { avatar: { shape: 'moon', image: PAYLOAD }, avatarUpdatedAt: 't1' }) + assert.deepEqual(dropped.avatar, { shape: 'moon' }) + + const kept = {} + applyPublishedLook(kept, { avatar: { image: 'https://ok.example/a.png' }, avatarUpdatedAt: 't2' }) + assert.deepEqual(kept.avatar, { image: 'https://ok.example/a.png' }) +}) + +test('cssImageUrl refuses every value that could close the url or the rule (#2646)', () => { + for (const bad of [ + 'https://ok.example/a".png', + "https://ok.example/a'.png", + 'https://ok.example/a(.png', + 'https://ok.example/a).png', + 'https://ok.example/a\\.png', + 'https://ok.example/a\n.png', + 'https://ok.example/a\u0007.png', + 'javascript:alert(1)', + 'data:image/png;base64,AA");}body{background:red}', + '', + ]) assert.equal(cssImageUrl(bad), null, bad) + + assert.equal(cssImageUrl('https://ok.example/a.png'), 'url("https://ok.example/a.png")') + assert.equal(safeLookImage('data:image/webp;base64,AAAA'), 'data:image/webp;base64,AAAA') + assert.equal(safeLookImage('https://ok.example/a.png'), 'https://ok.example/a.png') +}) diff --git a/docs/subsystems/core-client.md b/docs/subsystems/core-client.md index ee011b00d..9917c0464 100644 --- a/docs/subsystems/core-client.md +++ b/docs/subsystems/core-client.md @@ -123,7 +123,7 @@ The overlay (`flybot-slots.js` `FlyBotApprovals`) is the fallback, not a second - A friend's remote Bot is stored and delivered by its handle (`@.`, what `sendUserMentions` matches), and the user reads one name for it on every surface (#967): `remoteMentionLabel` in `shared/remote-label.js` — the sidebar row's title (`friendBotTitle`, #498), and on a title two different handles share the home too, so two friends' `Chief` stay apart. The `@` menu inserts that name (`remoteMentionRows` in `flybot-registry.js`, so a row shows what it inserts), the bubble chips it (`mentionableBots` / `userTextParts` in `src/client/user-message.js`), and the sidebar preview renders it (`nameMentions` in `src/client/text.js`). `mentionableBots` reads `roster.remoteBots` as well as `roster.bots` and the room's seats, so a friend's Bot chips before it has a chat of its own and inside a group it holds no seat in; it also keeps the handle as a second name of the same Bot, so a line written before this change still chips, and a chip whose Bot has no chat yet is a mark, not a button that would open nothing. Names come from the sanitized roster records, never from message text, so a remote-controlled name reaches the UI only through them (#71; display names, #9). The handle stays the routing key: the menu's text is a label, not a transport token. - In a face-to-face room the bubble matches a mention by every alias the room host routes on (`faceAliases` in `shared/remote-label.js`, the same list `face-mentions.js` uses), so `@SelfA's Chief` typed on B is A's own Chief on A. The chip shows `@` and the seat label (`faceSeatLabel`, `SelfA · Chief`) on both homes, a bare name means the writer's own Bot (my line tries my seats first, a friend's line the friend's, whatever order the room lists its seats in, as `faceCandidates` does), a friend's Bot outside the room is not matched, and one space typed after a picked `@Name ` (the menu inserts its own) is dropped from the drawing (#305). -- A remote Bot's generated look is seeded by its handle (`remoteSeed` in `src/client/characters.js`), never by its session id. When Bot Social sends `avatar` / `color` / `avatar_updated_at`, `flybot.remote.publish` stores them on the remote-chat record (cached by `avatar_updated_at`). `lookBotForRemote` overlays a person payload's look (face-room `members[]` is the source for room avatars). A stored `color`/`avatar` still wins over the seed; missing look stays handle-seeded (#137). The @ menu icon (`menuIcons`) and the user-bubble mention chip (`withMentionLook` in `user-message.js`) draw that same record through `markBotForMention`. A stand-in that keeps the look only on `remote` is not drawn: `lookOf` does not read it, and the handle seed (a green moon for `@chief.selfa1025.home`) was showing beside the stored blue whale (#1088). The seed remains the fallback when neither the chat nor the face-room seat has a look. A friend's file-chip anchor uses `lookAvoidingLocals`, the same decision as `BotMark`; a local Bot still owns a shared `@Name` chip (#963). +- A remote Bot's generated look is seeded by its handle (`remoteSeed` in `src/client/characters.js`), never by its session id. When Bot Social sends `avatar` / `color` / `avatar_updated_at`, `flybot.remote.publish` stores them on the remote-chat record (cached by `avatar_updated_at`). `lookBotForRemote` overlays a person payload's look (face-room `members[]` is the source for room avatars). A stored `color`/`avatar` still wins over the seed; missing look stays handle-seeded (#137). The @ menu icon (`menuIcons`) and the user-bubble mention chip (`withMentionLook` in `user-message.js`) draw that same record through `markBotForMention`. A stand-in that keeps the look only on `remote` is not drawn: `lookOf` does not read it, and the handle seed (a green moon for `@chief.selfa1025.home`) was showing beside the stored blue whale (#1088). The seed remains the fallback when neither the chat nor the face-room seat has a look. A friend's file-chip anchor uses `lookAvoidingLocals`, the same decision as `BotMark`; a local Bot still owns a shared `@Name` chip (#963). The page-wide mention stylesheet (`src/client/mention-style.js`) escapes what it interpolates (#2646): `cssImageUrl` (`src/shared/look-image.js`) answers `url("…")` only for a plain https URL or a base64 raster image and `null` for a value carrying a quote, a paren, a backslash, whitespace or a control character, and `mentionCss` then draws the colour/mask look — a friend's Bot's image URL cannot close the declaration or add a page-wide rule. Tests: `core/test/mention-style.test.js`. - A shared display name does not restyle a local Bot (#963). The `@Name` file chip (`mentionCss` in `src/client/mention-style.js`) uses the local Bot's look when a friend has the same name; that friend's own mention anchor keeps the friend's color, and a name only a friend has keeps that friend's look. `indexRoster` does not let a remote chat replace a local Bot's id or session keys, and `rosterBot` resolves a member id to the local Bot. `@Name` in a bubble (`linkMentions`) names the local Bot when both are listed. A group reply that is not remote resolves through `rosterBot` before the index. The friend's own row keeps the friend's look, unless that drawn look is the same picture as the local Bot of the same display name, in which case the row steps its shape and the local Bot stays as stored (#1069). - A friend's 1:1 chat (#443): `AssistantCell` gives the reply bubbles a `bt-lead` `BotMark` from the chat record (`friendLead`; none on a `Not sent:` note). The activity row (`activity-row.js`; host `[data-chat-flow] [data-chat-running]`, nested under `conversation.chat.flow` on DSH 0.2.1+ — #1626) stays up while `awaitingReply` (`awaiting.js`, `roster.remoteAwaiting`, 10 min cap) holds. Between the send turn and the echo turn it sits in the last turn's tail, with a 1.5 s grace and `data-carry` so there is no second entrance, which keeps one row from send to reply. The social `PeerThinking` line is for face rooms only. While the row is up in a friend chat and no local tool is the newest activity, its phrase is **Waiting for their approval** (等对方批准) instead of the thinking pool (`Sorting out the details` and the rest): that wait is the other home holding the reply, and this home cannot see the card (#1087). A local tool line still wins. A friend chat shows the row for a run that started from my submission and, between runs, while `awaitingReply` holds. The friend's reply run (the echo, which has no submission) hides it at once, and only the end of my send run gets the grace. The turn-tail hiding rules in `styles.js` spare `.bt-typing` and its tail item, or the row between runs would be drawn with `display:none`. - A friend's Bot the owner hid, a remote chat marked `gone`/`hidden`, and a home-level handle (`@friend.home`, not `@bot.friend.home`) must not reappear in the sidebar, the **发给 / 和谁聊…** picker, `@` mentions, or `list_bots` friends lines (`isListedRemoteChat`). `flybot.remote.publish` skips those handles so remote-chat sync marks the persisted session gone/hidden instead of opening it again; history is kept. Handle comparison is case-insensitive (`normRemoteHandle`). diff --git a/docs/subsystems/core-host.md b/docs/subsystems/core-host.md index f2bbe9b16..ad1654764 100644 --- a/docs/subsystems/core-host.md +++ b/docs/subsystems/core-host.md @@ -43,6 +43,7 @@ The server side of the patched `ds-bot` core (`core/`): the part of [ds-bot-core | `core/src/host/health.js` | `GET /api/health` (#2128, P-392): the one answer this instance gives without a session, and exactly four fields — `ok` (always `true`), `version` (`core/package.json`), `commit` (`git rev-parse HEAD` of the clone `core/` sits in, `null` for a package or path install with no `.git`) and `startedAt` (this process's start, from `process.uptime()`, stable until a restart). It is an **exact** WebServer route, which the web server matches before the `/api` prefix whose admission is the sign-in wall, so no cookie is needed and no other `/api` route changes; outside the Web composition there is no `webServer` and the route does not exist. A method other than `GET` answers 405. Tests: `core/test/health.test.js`. | | `core/src/host/room-membership.js` | Typed room participants (human / local bot / granted remote), authorship, membership-window history (#50); rooms with friends' Bots or other owners take granted local Bots only and evict a Bot that stops qualifying (#90). Face-to-face rooms (#83) reuse this model (`origin: 'face-to-face'`, room-only remotes without a person-grant, `upsertFaceRoom` / `syncFaceParticipants` / `applyFaceRoomLine`); a face-room local Bot is `viaCode` (brought by number), `evictUnshared` skips face rooms (membership is the server's), and a Bot that returns to the member list drops the empty local `left` line (#236). Refusing an ungranted local Bot next to a friend's Bot throws `ungrantedRefusal`: a message that names the Bot, the friend and the two ways out, plus `error.details` `{ reason: 'ungranted-local', bots, remotes }`; `channel.js` `fail` forwards a plain-object `details` (empty otherwise) and `client/api.js` puts it on the thrown error, where `client/new-chat.js` draws a card (**Copy invite link for {Bot}** via `__flybot__.social.createInvite`, **Leave {Bot} out**) read through `ungrantedLocalFix` in `shared/remote-label.js`; the same file's `copiedToClipboard` reports whether the invite link really reached the clipboard (none, or a refused write, is `false`), and the card then shows the link to copy by hand instead of saying it was copied. | | `core/src/host/face-mentions.js` | Who "@" calls in a face-to-face room (#287): every alias of a participant (`faceAliases`, kept in `shared/remote-label.js` so the bubble chips the same names, #305; bare name, the picker label `'s Chief` / ` · 's Bot`, ` · `, handle), local and friend's Bots told apart. `faceMentions().handles` is the handle of every Bot a line names (the explicit mention list a sender attaches, #293) and `mentionedByHandle` resolves a received list to my Bots (`known` counts handles the room has). | +| `core/src/shared/look-image.js` | The image a Bot's look may carry (#2646): `safeLookImage` keeps a plain `https?://` URL or a `data:image/(png|jpeg|webp|gif);base64,` string and answers `null` for a value carrying a quote, a paren, a backslash, whitespace or a control character; `cssImageUrl` writes the kept value as `url("…")` for the page-wide mention stylesheet. It is read at the entries — hooks.js `cleanRemoteLook` and remote-chat.js `applyPublishedLook` — and again at that sink (`client/mention-style.js`), so a friend's Bot cannot close the declaration or add a rule; a dropped image draws the colour/mask look. `plugins/social/src/look.js` carries the same test (png/jpeg/webp there; gif is a local stored form) and `plugins/social/test/look.test.js` keeps the two in step. Tests: `core/test/mention-style.test.js`. | | `core/src/host/reach.js` | Per-Bot grants and the owner-policy `reach()` check that hooks.js runs outside the plugin chain (#90); `rt.setGrants`, `rt.hasGrant`, `rt.hasAnyGrant`. A local Bot refused toward a friend's Bot is told that it is not shared with that friend and where to share it (Sharing, #273). `rt.reach` also reads the room a send already belongs to when the caller names none (#948): `sharedRoom` applies only to the `roomOnly` identity — the Bot holds no grant at all and came into that **face-to-face** room by its number (`isCodeJoined`) — and looks for the one such room where the **exact** target is still seated; a Bot that holds a grant, or a room of this home, is judged as it always was and keeps asking for an explicit `roomId`, so a Bot that joined a room by number — a `roomOnly` identity with no person-grant — may line that seat, which is the same allowance `reach()` already applies to an explicit `roomId`; the room itself is named outbound by the transport, never assumed here. A `user-mention` with no named room is also judged under `mentionRoom` (#1126): one live local or inbound group where that Bot is a member and the exact target is still a remote seat, including a seat a group invite admitted (`admittedBy: 'ticket'`). A plain line, `message_bot`, a seat that has left, and a stranger outside every such room stay on the grant rule. Face-to-face stays on `sharedRoom`. Tests: `core/test/reach-policy.test.js`, `core/test/flybot.test.js`. | | `core/src/host/schedules.js` | Scheduled tasks through DSH's schedule service: list, create, edit, copy, pause/resume, retry and delete for Settings → Scheduled tasks, each task's last run, and moving a Bot's tasks to its fresh part, which rewrites the stored row in place so the task keeps its id, its delivery history and the instant it was already due (#354, #473, #1245, #1287, see **Scheduled tasks: edit, pause, missed runs**, **Scheduled tasks: copy** and **Scheduled tasks: last run and Retry**). Also the `bot_schedules` tool: the Bot-wide list DSH's own `schedule_list` cannot give, group chats' own Sessions included (#628, see **Scheduled tasks: the Bot-wide list**). Tests: `core/test/schedules.test.js`. Tests for team quiet hours, its refusals and the settings write: `core/test/quiet-hours.test.js`; the rule itself is in `core/src/shared/quiet-hours.js` (#1166). The `every` selector takes `seconds` (60 to 604800) as well as the older `minutes` form, so a 90-second repeat survives an edit; a value below 60s or above 7 days is refused with the same sentence (#1651). A task name is at most 120 code points (#1895), so 120 emoji is a valid name and one more is refused. The list is a read of the caller's own rows: `schedulesView(actor)` reads each row's Session through the module's own `sourceOf(sessionId, actor)` (the rule #1321 uses for a copy's source and an edit's own Session), so another owner's task stays out and an actor-less call under scoping answers no rows, while an unenforced one-person install answers the whole host; the `schedule-*` writes keep their Session and target gates. A narrowed view also carries `someHidden: true` when a known row was dropped for the caller — the active and the paused loop set it alike — so an empty answer from a narrow caller is not read as a host with nothing scheduled; a row with no known owner hides nothing and sets no flag, and an unenforced install (no actor narrowing anything) never sets it either. | | `core/src/host/automation-default.js` | Automation tasks on by default (#378): once per profile, `offerBundle` asks DSH's plugin manager to switch on `@deepseek-ai/dsh-experimental-schedule-bundle` and writes the offer to `dsh-defaults.json` next to the team. Tests: `core/test/automation-default.test.js`. | diff --git a/docs/subsystems/plugin-runtime.md b/docs/subsystems/plugin-runtime.md index 30f0fde79..12208fb38 100644 --- a/docs/subsystems/plugin-runtime.md +++ b/docs/subsystems/plugin-runtime.md @@ -34,7 +34,7 @@ FlyBot's plugin API surface on top of the patched core: host hooks, client slots | File | Role | | --- | --- | -| `core/src/host/hooks.js` | Host hook points: send middleware, transports, grants, the owner-bound held-send approvals (#52), task hold/decide/delete seams (#54), and `bot.update({ name?, hidden? })` for plugin rename/hide (#452). A hold's waiting status is the shared rule `src/shared/approval-waiting.js` `isApprovalWaiting()`: `decide()`, `heldOf()` (the chat card; since P-538 it also carries the record's `reason`, so the bubble can say why it waits), `PENDING_MAX`, the in-flight filter and the `APPROVALS_KEPT` cleanup all ask it instead of comparing to `pending`, so a `pending_approval` hold is decidable, drawn as waiting, counted and never cleaned up mid-wait (#754). It also exposes `flybot.approvals.stats()` — per-Bot, per-status counts of held sends, read-only (#396/#57 direction). A dispatch that fails keeps its error on the record, and an error the sender marks `noRetry: true` — a refusal nothing can change, such as a friend’s Bot no longer shared — reaches the chat view as `retry: false` so the card draws no Retry (#954); every other failure stays retryable. Whether a failure can still be retried is one rule in `src/shared/held-retry.js` (#968): `canRetry(record)` reads the flags or, for an older record, the stored sentence, and `approvals.list` marks a failed terminal approval `retry: false` so a dock card and the chat card answer the same question the same way. Since #2409 the chat card's `heldOf()` map also carries the record's own `createdAt`, so `HeldReplyActions` can say how long the reply has waited. `rooms.list()` copies `sharedFrom.remoteRoomId`, `ticket` and `inviterHandle` when a room has them, so a mention of an inbound seat can name the host's room id inside the share line (#1126). | +| `core/src/host/hooks.js` | Host hook points: send middleware, transports, grants, the owner-bound held-send approvals (#52), task hold/decide/delete seams (#54), and `bot.update({ name?, hidden? })` for plugin rename/hide (#452). A hold's waiting status is the shared rule `src/shared/approval-waiting.js` `isApprovalWaiting()`: `decide()`, `heldOf()` (the chat card; since P-538 it also carries the record's `reason`, so the bubble can say why it waits), `PENDING_MAX`, the in-flight filter and the `APPROVALS_KEPT` cleanup all ask it instead of comparing to `pending`, so a `pending_approval` hold is decidable, drawn as waiting, counted and never cleaned up mid-wait (#754). It also exposes `flybot.approvals.stats()` — per-Bot, per-status counts of held sends, read-only (#396/#57 direction). A dispatch that fails keeps its error on the record, and an error the sender marks `noRetry: true` — a refusal nothing can change, such as a friend’s Bot no longer shared — reaches the chat view as `retry: false` so the card draws no Retry (#954); every other failure stays retryable. Whether a failure can still be retried is one rule in `src/shared/held-retry.js` (#968): `canRetry(record)` reads the flags or, for an older record, the stored sentence, and `approvals.list` marks a failed terminal approval `retry: false` so a dock card and the chat card answer the same question the same way. Since #2409 the chat card's `heldOf()` map also carries the record's own `createdAt`, so `HeldReplyActions` can say how long the reply has waited. `rooms.list()` copies `sharedFrom.remoteRoomId`, `ticket` and `inviterHandle` when a room has them, so a mention of an inbound seat can name the host's room id inside the share line (#1126). `cleanRemoteLook` keeps a published look's image only when `safeLookImage` (`src/shared/look-image.js`) accepts it, because that value reaches the page-wide mention stylesheet as `url("…")` (#2646); an image carrying a quote, a paren, a backslash, whitespace or a control character is dropped and the Bot keeps its colour and shape. | | `core/src/host/flybot-feed.js` | Activity feed: folds a Bot Session's `session/event`s into `flybot.on('turn.start' \| 'turn.end' \| 'tool.start' \| 'tool.end' \| 'exchange.delivered' \| 'decision')` (#241). `turn.end` also carries the turn's token counts (summed from its `assistant/message` usage, floored and capped, counts only) under `tokens` (#357). `decision` is fired by `decisions.js` (#396), not folded from a Session event. Since #1912 `cleanLabel` bounds its text through `cut` in `text.js`, so a cleaned tool name or remote handle never ends on half of a surrogate pair. | | `core/src/client/flybot-slots.js` | Client slots, including the approval card queue (`flybot.approvals`, `flybot/decide`) and `flybot.sidebar.structure` (`parentOf`, optional `include`, `menuItems({ bot, readOnly })`, `nodeActions`); `readOnly` is true only in a window that holds the team read-only, the rule `workflows.js` applies to `roster.readOnly`, so a structure entry hides a write the host would refuse there (#2238). Every `flybot.sidebar.section` entry is wrapped in `div.bt-flybot-section`, and a wrapper a section leaves empty (its component renders null) draws no separator: the `:empty` rule zeroes that margin, border and padding, keyed on emptiness rather than on a section id, so the next null-rendering section is covered too (#818; `core/test/flybot-section-empty.test.js` renders the host entry and pins both rules). Since #2409 a waiting card's head line carries how long the hold has waited beside it (`Waiting for approval · 12 min`, `relativeShort()` from `client/text.js`); a failed `Not sent` card and a record without `createdAt` draw no age. Its Approve / Reject and the held-reply card both ask `approval-waiting.js` `isApprovalWaiting()` (`pending` and `pending_approval` wait), so a hold carrying the spelling Bot Social's own outbound hold uses stays answerable (#734). Its dock card draws **Retry** only while `src/shared/held-retry.js` `canRetry(approval)` allows it — a terminal failure asks one shared predicate instead of `status === 'failed'` (#968) — and it publishes `globalThis.__flybot__.canRetry` so a classic-script plugin's own dock asks the same rule rather than keeping a second copy. | | `core/src/client/flybot-registry.js` | Registry. A friend's Bot row in the @ menu carries `remoteMentionLabel` — the one name its bubble and the sidebar preview wear — so the menu inserts the name the user reads, while the row keeps the handle as its value and the send still routes by handle (#967). | diff --git a/docs/subsystems/social.md b/docs/subsystems/social.md index df0208d0f..2fa08e632 100644 --- a/docs/subsystems/social.md +++ b/docs/subsystems/social.md @@ -34,7 +34,7 @@ At phone width (`<= 640 px`, the last block in `client.js's stylesheet`) this pa | `plugins/social/src/social.js` | Host: roster, grants, inbox, `social/status` pending-approval queue, Approve / Reject, hide-remote, Manage access, shared-room approvals provider, face-to-face join/poll/leave/start/send and `social/face-auto`. | | `plugins/social/src/server-config.js` | Which server this FlyBot uses (#337): address validation (`parseServerUrl`), the `GET /v1/platform` probe (`probeServer`), and `createServerController`, which starts the social layer for the effective server and switches it live. `index.js` wires it and registers `social/server`, `social/server-check`, `social/server-set`. | | `plugins/social/src/mcp-client.js` | Owner MCP client plus isolated grant/invite REST fallbacks (`listGrants`, `revokeGrant`, `updateBotProfile`, `declineGrant`, invites). | -| `plugins/social/src/look.js` | Map Bot Social profile look fields to FlyBot `{ color, avatar }` (optional on older servers). | +| `plugins/social/src/look.js` | Map Bot Social profile look fields to FlyBot `{ color, avatar }` (optional on older servers). A look's image is pasted into the page-wide mention stylesheet as `url("…")`, so `lookImage` keeps only a plain `https?://` URL or a `data:image/(png|jpeg|webp);base64,` string and drops one carrying a quote, a paren, a backslash, whitespace or a control character (#2646); `core/src/shared/look-image.js` is the same test at the core entries and sink, and `test/look.test.js` keeps the two in step. | | `plugins/social/src/face-rooms.js` | Face-to-face REST client (join/list/get/start/leave/messages, mint/revoke/preview/join invites, `parseInviteLink` / `parseFaceInviteLink`), error mapping, event rendering, peer-string sanitization. Auto-send is MCP in `social.js`. | | `plugins/social/src/presence.js` | "Name is thinking…" for friends' Bots (#297): sends `set_presence` for my Bots' turns that answer a friend or run in an open face room (`turn.start` / `turn.end`, heartbeat), polls `list_presence` only while a social chat asks (`social/typing`), clears a line when that Bot's message arrives. Gated on the `presence` feature. | | `plugins/social/src/face-title.js` | Face-to-face room titles (`Face-to-face · Alice, Bob · 482913`). Ships in the plugin so a packaged install can import (#153); core keeps its own copy for hosted rooms. | diff --git a/patches/ds-bot/0008-client-bundle.patch b/patches/ds-bot/0008-client-bundle.patch index ecb93b599..add89549b 100644 --- a/patches/ds-bot/0008-client-bundle.patch +++ b/patches/ds-bot/0008-client-bundle.patch @@ -2274,7 +2274,7 @@ diff --git a/client.js b/client.js var formatTokens = (count) => count >= 1e6 ? `${(count / 1e6).toFixed(1)}M` : count >= 1e3 ? `${(count / 1e3).toFixed(1)}K` : String(count); function usageOf(list, ids) { let input = 0; -@@ -1624,694 +1624,2752 @@ function usageOf(list, ids) { +@@ -1624,694 +1624,2776 @@ function usageOf(list, ids) { const hit = input > 0 ? Math.round(cacheRead / input * 100) : 0; return t("{tokens} tok · Cache hit {hit}%", { tokens: formatTokens(input + output), hit }); } @@ -5058,6 +5058,29 @@ diff --git a/client.js b/client.js +} +var allThemes = () => ({ ...THEMES, ...customThemes }); + ++// src/shared/look-image.js ++var LOOK_IMAGE_MAX = 65536; ++var DATA_IMAGE = /^data:image\/(png|jpeg|webp|gif);base64,[A-Za-z0-9+/]+=*$/i; ++var HTTP = /^https?:\/\//i; ++var UNSAFE = /["'()\\\u0000-\u001f\u007f\s<>]/; ++function safeLookImage(value, max = LOOK_IMAGE_MAX) { ++ if (typeof value !== "string") return null; ++ const text2 = value.trim(); ++ if (text2.length === 0 || text2.length > max || UNSAFE.test(text2)) return null; ++ if (DATA_IMAGE.test(text2)) return text2; ++ if (!HTTP.test(text2)) return null; ++ try { ++ new URL(text2); ++ } catch { ++ return null; ++ } ++ return text2; ++} ++function cssImageUrl(value) { ++ const image = safeLookImage(value); ++ return image === null ? null : `url("${image}")`; ++} ++ +// src/client/mention-style.js +function chipKey(bot) { + if (/\s/.test(bot?.name)) return null; @@ -5079,7 +5102,8 @@ diff --git a/client.js b/client.js + const chip = key !== null && owner.get(key) === bot ? `[class*="_userRow"] [data-ref-chip="file"][title="@${String(bot.name).replace(/["\\]/g, "\\$&")}" i]` : ""; + const look = bot?.remote ? lookAvoidingLocals(bot) : lookOf(bot); + const marks = chip ? `${anchor}::before,${chip}::before` : `${anchor}::before`; -+ const mark = look.image ? `${marks}{background:center/cover url("${look.image}");-webkit-mask:none;mask:none;border-radius:50%}` : `${marks}{background:${paintCss(look.color)};-webkit-mask-image:${maskOf(look.shape)};mask-image:${maskOf(look.shape)}}`; ++ const image = cssImageUrl(look.image); ++ const mark = image ? `${marks}{background:center/cover ${image};-webkit-mask:none;mask:none;border-radius:50%}` : `${marks}{background:${paintCss(look.color)};-webkit-mask-image:${maskOf(look.shape)};mask-image:${maskOf(look.shape)}}`; + const chipBase = chip ? `${chip}{color:inherit;font-weight:600}${chip}>svg{display:none}${chip}::before{content:"";display:inline-block;flex:none;width:16px;height:16px;margin-right:3px;vertical-align:-3px;-webkit-mask:${DEFAULT_MASK} center/contain no-repeat;mask:${DEFAULT_MASK} center/contain no-repeat}` : ""; + return `${anchor}{color:${inkText(look.color)}}${chipBase}${mark}`; + }).join("\n"); diff --git a/patches/ds-bot/files/src/client/mention-style.js b/patches/ds-bot/files/src/client/mention-style.js index dd2e33655..336fc5e00 100644 --- a/patches/ds-bot/files/src/client/mention-style.js +++ b/patches/ds-bot/files/src/client/mention-style.js @@ -1,5 +1,6 @@ import { lookAvoidingLocals, lookOf, maskOf, DEFAULT_MASK } from './characters.js' import { inkText, paintCss } from './inks.js' +import { cssImageUrl } from '../shared/look-image.js' import { MENTION_ORIGIN } from './text.js' // One @Name file chip per display name. A local Bot owns the chip when a @@ -30,8 +31,11 @@ export function mentionCss(bots) { // sidebar has already stepped off (#1088). A local Bot still owns a shared @Name chip. const look = bot?.remote ? lookAvoidingLocals(bot) : lookOf(bot) const marks = chip ? `${anchor}::before,${chip}::before` : `${anchor}::before` - const mark = look.image - ? `${marks}{background:center/cover url("${look.image}");-webkit-mask:none;mask:none;border-radius:50%}` + // A friend's Bot publishes this URL, so the value is checked here as well as where it + // enters; one that could leave url("…") draws the colour/mask look instead (#2646). + const image = cssImageUrl(look.image) + const mark = image + ? `${marks}{background:center/cover ${image};-webkit-mask:none;mask:none;border-radius:50%}` : `${marks}{background:${paintCss(look.color)};-webkit-mask-image:${maskOf(look.shape)};mask-image:${maskOf(look.shape)}}` const chipBase = chip ? `${chip}{color:inherit;font-weight:600}${chip}>svg{display:none}${chip}::before{content:"";display:inline-block;flex:none;width:16px;height:16px;margin-right:3px;vertical-align:-3px;-webkit-mask:${DEFAULT_MASK} center/contain no-repeat;mask:${DEFAULT_MASK} center/contain no-repeat}` : '' return `${anchor}{color:${inkText(look.color)}}${chipBase}${mark}` diff --git a/patches/ds-bot/files/src/host/hooks.js b/patches/ds-bot/files/src/host/hooks.js index 8a1b886b8..072120ccb 100644 --- a/patches/ds-bot/files/src/host/hooks.js +++ b/patches/ds-bot/files/src/host/hooks.js @@ -13,6 +13,7 @@ import { createHash, randomUUID } from 'node:crypto' import { createUserMessage } from '@deepseek-ai/dsh-llm' import { isApprovalWaiting } from '../shared/approval-waiting.js' import { canRetry } from '../shared/held-retry.js' +import { safeLookImage } from '../shared/look-image.js' import { mentionSpans } from '../shared/mentions.js' import { isHomeLevelRemote, remoteMentionLabel } from '../shared/remote-label.js' import { cleanName, stripSessionChips } from './delivery.js' @@ -937,8 +938,8 @@ export function install(rt) { const avatar = {} if (typeof src.shape === 'string' && /^[a-z0-9][a-z0-9_-]{0,31}$/.test(src.shape)) avatar.shape = src.shape if (typeof src.image === 'string') { - const image = src.image.trim() - if (image.length <= 65536 && /^(https?:\/\/|data:image\/)/i.test(image) && !/[\s<>]/.test(image)) avatar.image = image + const image = safeLookImage(src.image) + if (image) avatar.image = image } if (typeof src.emoji === 'string') { const emoji = src.emoji.trim() diff --git a/patches/ds-bot/files/src/host/remote-chat.js b/patches/ds-bot/files/src/host/remote-chat.js index 84b01699f..a9dd1d853 100644 --- a/patches/ds-bot/files/src/host/remote-chat.js +++ b/patches/ds-bot/files/src/host/remote-chat.js @@ -15,6 +15,7 @@ import { createUserMessage, LlmAdapter } from '@deepseek-ai/dsh-llm' import { SOURCE_KIND } from './constants.js' import { cleanName, fenceUntrusted } from './delivery.js' import { isHomeLevelRemote, normRemoteHandle } from '../shared/remote-label.js' +import { safeLookImage } from '../shared/look-image.js' import { cut } from './text.js' export const REMOTE_PROVIDER = 'flybot-remote' @@ -24,6 +25,36 @@ const SENT_KEPT = 500 export const isRemoteChat = bot => typeof bot?.remote?.handle === 'string' const textOf = content => (Array.isArray(content) ? content.filter(block => block?.type === 'text').map(block => block.text).join('\n') : String(content ?? '')) +// A published look reaches the mention stylesheet, so its image is checked here as well as +// at that sink: a value that could leave url("…") is dropped and the Bot keeps no photo (#2646). +export function applyPublishedLook(bot, member) { + if (!bot || !member) return false + const at = typeof member.avatarUpdatedAt === 'string' ? member.avatarUpdatedAt : '' + if (at && bot.avatarUpdatedAt === at) return false + let changed = false + if (typeof member.color === 'string' && member.color && bot.color !== member.color) { + bot.color = member.color + changed = true + } + if (member.avatar && typeof member.avatar === 'object') { + const avatar = { ...member.avatar } + if (avatar.image !== undefined) { + const image = safeLookImage(avatar.image) + if (image === null) delete avatar.image + else avatar.image = image + } + if (JSON.stringify(bot.avatar) !== JSON.stringify(avatar)) { + bot.avatar = avatar + changed = true + } + } + if (at && bot.avatarUpdatedAt !== at) { + bot.avatarUpdatedAt = at + changed = true + } + return changed +} + // Sidebar / chat preview for a failed 1:1 send to a friend's Bot. Keep it one short // English line with a next step; never dump `Bot Social send_as_member: …`. export function notSentLine(error) { @@ -193,27 +224,6 @@ export function install(rt) { } let opening = Promise.resolve() - function applyPublishedLook(bot, member) { - if (!bot || !member) return false - const at = typeof member.avatarUpdatedAt === 'string' ? member.avatarUpdatedAt : '' - if (at && bot.avatarUpdatedAt === at) return false - let changed = false - if (typeof member.color === 'string' && member.color && bot.color !== member.color) { - bot.color = member.color - changed = true - } - if (member.avatar && typeof member.avatar === 'object') { - if (JSON.stringify(bot.avatar) !== JSON.stringify(member.avatar)) { - bot.avatar = { ...member.avatar } - changed = true - } - } - if (at && bot.avatarUpdatedAt !== at) { - bot.avatarUpdatedAt = at - changed = true - } - return changed - } // Creates the chat for a remote Bot (once), or refreshes its name, home label, and look. function open({ handle, name, home, homeName, plugin, color, avatar, avatarUpdatedAt }) { if (isHomeLevelRemote({ handle, home })) return Promise.resolve(undefined) diff --git a/patches/ds-bot/files/src/shared/look-image.js b/patches/ds-bot/files/src/shared/look-image.js new file mode 100644 index 000000000..ffbf47589 --- /dev/null +++ b/patches/ds-bot/files/src/shared/look-image.js @@ -0,0 +1,27 @@ +// The image URL a Bot's look may carry. A friend's Bot publishes its own look, and the +// page-wide mentions stylesheet pastes `avatar.image` into `url("…")` (client/mention-style.js), +// so a value that can close the quote, the url() or the rule is dropped where it enters — +// hooks.js `cleanRemoteLook` and remote-chat.js `applyPublishedLook` — and again at that sink. +// A dropped image draws the colour/mask look instead. The social plugin carries the same test +// for its own threshold (no gif there); plugins/social/test/look.test.js keeps the two in step. +export const LOOK_IMAGE_MAX = 65536 +const DATA_IMAGE = /^data:image\/(png|jpeg|webp|gif);base64,[A-Za-z0-9+/]+=*$/i +const HTTP = /^https?:\/\//i +const UNSAFE = /["'()\\\u0000-\u001f\u007f\s<>]/ + +// The trimmed image URL, or null when it is not a plain https URL or a base64 raster image. +export function safeLookImage(value, max = LOOK_IMAGE_MAX) { + if (typeof value !== 'string') return null + const text = value.trim() + if (text.length === 0 || text.length > max || UNSAFE.test(text)) return null + if (DATA_IMAGE.test(text)) return text + if (!HTTP.test(text)) return null + try { new URL(text) } catch { return null } + return text +} + +// The same value, ready to sit inside a stylesheet as `url("…")`; null means draw the colour. +export function cssImageUrl(value) { + const image = safeLookImage(value) + return image === null ? null : `url("${image}")` +} diff --git a/patches/ds-bot/files/test/mention-style.test.js b/patches/ds-bot/files/test/mention-style.test.js new file mode 100644 index 000000000..5ee651d84 --- /dev/null +++ b/patches/ds-bot/files/test/mention-style.test.js @@ -0,0 +1,69 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { mentionCss } from '../src/client/mention-style.js' +import { applyPublishedLook } from '../src/host/remote-chat.js' +import { cssImageUrl, safeLookImage } from '../src/shared/look-image.js' + +// The payload from #2646: a friend's Bot publishes a look whose image closes url("…"), +// closes the rule, and adds a page-wide `body{…}` rule to the mention stylesheet. +const PAYLOAD = `data:image/png;base64,AA");}body{background:url(https://evil.example/beacon)}.z{'` +const evil = { + id: 'remote:evil.home', + name: 'Evil', + remote: { handle: 'evil.home' }, + avatar: { image: PAYLOAD }, +} + +const urlQuotes = css => (css.match(/url\("/g) ?? []).length +const urlCloses = css => (css.match(/"\)/g) ?? []).length + +test('a payload image cannot leave url("…") in the page-wide mention stylesheet (#2646)', () => { + const css = mentionCss([evil]) + assert.equal(css.includes('body{'), false) + assert.equal(css.includes('evil.example'), false) + assert.equal(urlQuotes(css), urlCloses(css)) + // The sink drops the photo and draws the colour/mask look instead. + assert.match(css, /-webkit-mask-image:/) + assert.equal(css.includes(PAYLOAD), false) +}) + +test('a valid image still draws the photo in the mention stylesheet (#2646)', () => { + // A display name with a space owns no @Name chip, so the DEFAULT_MASK url() stays out of the line. + const friend = { id: 'remote:ok', name: 'Ok Friend', remote: { handle: 'ok.home' }, avatar: { image: 'https://ok.example/a.png' } } + const css = mentionCss([friend]) + assert.match(css, /background:center\/cover url\("https:\/\/ok\.example\/a\.png"\)/) + assert.equal(urlQuotes(css), 1) + assert.equal(urlCloses(css), 1) + // A local Bot's gif avatar is a stored form and keeps drawing (roster.js accepts it). + const local = { id: 'local', name: 'Local Bot', avatar: { image: 'data:image/gif;base64,R0lGODlhAQABAAAAACw=' } } + assert.match(mentionCss([local]), /url\("data:image\/gif;base64,R0lGODlhAQABAAAAACw="\)/) +}) + +test('a published look drops an unsafe image and keeps a plain one (#2646)', () => { + const dropped = {} + applyPublishedLook(dropped, { avatar: { shape: 'moon', image: PAYLOAD }, avatarUpdatedAt: 't1' }) + assert.deepEqual(dropped.avatar, { shape: 'moon' }) + + const kept = {} + applyPublishedLook(kept, { avatar: { image: 'https://ok.example/a.png' }, avatarUpdatedAt: 't2' }) + assert.deepEqual(kept.avatar, { image: 'https://ok.example/a.png' }) +}) + +test('cssImageUrl refuses every value that could close the url or the rule (#2646)', () => { + for (const bad of [ + 'https://ok.example/a".png', + "https://ok.example/a'.png", + 'https://ok.example/a(.png', + 'https://ok.example/a).png', + 'https://ok.example/a\\.png', + 'https://ok.example/a\n.png', + 'https://ok.example/a\u0007.png', + 'javascript:alert(1)', + 'data:image/png;base64,AA");}body{background:red}', + '', + ]) assert.equal(cssImageUrl(bad), null, bad) + + assert.equal(cssImageUrl('https://ok.example/a.png'), 'url("https://ok.example/a.png")') + assert.equal(safeLookImage('data:image/webp;base64,AAAA'), 'data:image/webp;base64,AAAA') + assert.equal(safeLookImage('https://ok.example/a.png'), 'https://ok.example/a.png') +}) diff --git a/plugins/social/src/look.js b/plugins/social/src/look.js index d56b7a16c..88ef481f8 100644 --- a/plugins/social/src/look.js +++ b/plugins/social/src/look.js @@ -30,6 +30,23 @@ function isEmojiAvatar(value) { return true } +// Same test as core/src/shared/look-image.js (test/look.test.js keeps the two in step, and +// the core copy alone allows gif): a look's image reaches the page-wide mention stylesheet +// as url("…"), so a value carrying a quote, a paren, a backslash, whitespace or a control +// character is dropped here rather than escaped there (#2646). +const UNSAFE_IMAGE = /["'()\\\u0000-\u001f\u007f\s<>]/ +const HTTP_IMAGE = /^https?:\/\//i + +export function lookImage(value, max = AVATAR_MAX) { + if (typeof value !== 'string') return null + const text = value.trim() + if (text.length === 0 || text.length > max || UNSAFE_IMAGE.test(text)) return null + if (DATA_IMAGE.test(text)) return text + if (!HTTP_IMAGE.test(text)) return null + try { new URL(text) } catch { return null } + return text +} + export function lookFromServer(row = {}) { const out = {} const rawColor = String(row.color ?? '').trim() @@ -48,8 +65,9 @@ export function lookFromServer(row = {}) { } } else if (raw && typeof raw === 'object') { if (typeof raw.shape === 'string' && PRESET.test(raw.shape)) avatar.shape = raw.shape - if (typeof raw.image === 'string' && raw.image.length <= AVATAR_MAX && /^(https?:\/\/|data:image\/)/i.test(raw.image.trim()) && !/[\s<>]/.test(raw.image)) { - avatar.image = raw.image.trim() + if (typeof raw.image === 'string') { + const image = lookImage(raw.image) + if (image) avatar.image = image } if (typeof raw.emoji === 'string' && isEmojiAvatar(raw.emoji)) avatar.emoji = raw.emoji.trim().slice(0, 16) } diff --git a/plugins/social/test/look.test.js b/plugins/social/test/look.test.js new file mode 100644 index 000000000..acec8c6af --- /dev/null +++ b/plugins/social/test/look.test.js @@ -0,0 +1,56 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { lookFromServer, lookImage } from '../src/look.js' +import { safeLookImage } from '../../../core/src/shared/look-image.js' + +// #2646: a friend's published look is pasted into the page-wide mention stylesheet as +// url("…"), so a value carrying a quote, a paren, a backslash, whitespace or a control +// character is dropped here too; the core sink is not the only guard. +const PAYLOAD = `data:image/png;base64,AA");}body{background:url(https://evil.example/beacon)}.z{'` + +test('the object look form drops an image that could leave url("…") (#2646)', () => { + assert.equal(lookFromServer({ avatar: { image: PAYLOAD } }).avatar, undefined) + assert.equal(lookFromServer({ avatar: { image: `https://ok.example/a".png` } }).avatar, undefined) + assert.equal(lookFromServer({ avatar: { image: `https://ok.example/a).png` } }).avatar, undefined) + assert.equal(lookFromServer({ avatar: { image: 'data:image/svg+xml;base64,AAAA' } }).avatar, undefined) + assert.equal(lookFromServer({ avatar: { image: 'javascript:alert(1)' } }).avatar, undefined) + // A shape beside the refused image survives. + assert.deepEqual(lookFromServer({ avatar: { shape: 'moon', image: PAYLOAD } }).avatar, { shape: 'moon' }) +}) + +test('the object look form keeps a plain https or base64 image (#2646)', () => { + assert.deepEqual(lookFromServer({ avatar: { image: 'https://ok.example/a.png' } }).avatar, { image: 'https://ok.example/a.png' }) + assert.deepEqual(lookFromServer({ avatar: { image: 'data:image/png;base64,AAAA' } }).avatar, { image: 'data:image/png;base64,AAAA' }) +}) + +test('the string look form still drops the same payload (#2646)', () => { + assert.equal(lookFromServer({ avatar: PAYLOAD, avatar_kind: 'image' }).avatar, undefined) + assert.equal(lookFromServer({ avatar: PAYLOAD }).avatar, undefined) +}) + +test('the plugin predicate and core/src/shared/look-image.js agree (#2646)', () => { + // The core copy alone allows a local gif (roster.js stores one); everything else matches. + const cases = [ + 'https://ok.example/a.png', + 'http://ok.example/a.png', + 'https://ok.example/a;b.png', + 'data:image/png;base64,AAAA', + 'data:image/jpeg;base64,AA==', + 'data:image/webp;base64,AAAA', + PAYLOAD, + `https://ok.example/a".png`, + `https://ok.example/a'.png`, + `https://ok.example/a(.png`, + `https://ok.example/a).png`, + `https://ok.example/a\\.png`, + 'https://ok.example/a b.png', + 'https://ok.example/a