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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/2646.fixed.md
Original file line number Diff line number Diff line change
@@ -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)).
26 changes: 25 additions & 1 deletion core/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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");
Expand Down
8 changes: 6 additions & 2 deletions core/src/client/mention-style.js
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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}`
Expand Down
5 changes: 3 additions & 2 deletions core/src/host/hooks.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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()
Expand Down
52 changes: 31 additions & 21 deletions core/src/host/remote-chat.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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) {
Expand Down Expand Up @@ -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)
Expand Down
27 changes: 27 additions & 0 deletions core/src/shared/look-image.js
Original file line number Diff line number Diff line change
@@ -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}")`
}
69 changes: 69 additions & 0 deletions core/test/mention-style.test.js
Original file line number Diff line number Diff line change
@@ -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')
})
Loading
Loading