fix(desktop): address security scan findings

This commit is contained in:
Brooklyn Nicholson
2026-05-04 23:43:00 -05:00
parent 023730314b
commit 301c698491
6 changed files with 151 additions and 28 deletions
+17 -24
View File
@@ -38,7 +38,7 @@ import { useComposerGlassTweaks } from './hooks/use-composer-glass-tweaks'
import { useSlashCompletions } from './hooks/use-slash-completions'
import { useVoiceConversation } from './hooks/use-voice-conversation'
import { useVoiceRecorder } from './hooks/use-voice-recorder'
import { composerHtml, composerPlainText, escapeHtml, placeCaretEnd, refChipHtml, RICH_INPUT_SLOT } from './rich-editor'
import { composerPlainText, placeCaretEnd, refChipElement, renderComposerContents, RICH_INPUT_SLOT } from './rich-editor'
import { SkinSlashPopover } from './skin-slash-popover'
import { ComposerTriggerPopover } from './trigger-popover'
import type { ChatBarProps } from './types'
@@ -229,7 +229,7 @@ export function ChatBar({
const editor = editorRef.current
if (editor && document.activeElement !== editor && composerPlainText(editor) !== draft) {
editor.innerHTML = composerHtml(draft)
renderComposerContents(editor, draft)
}
}, [draft])
@@ -346,9 +346,7 @@ export function ChatBar({
refs.forEach((ref, index) => {
const match = ref.match(/^@([^:]+):(.+)$/)
const holder = document.createElement('span')
holder.innerHTML = match ? refChipHtml(match[1], match[2]) : escapeHtml(ref)
fragment.appendChild(holder.firstChild || document.createTextNode(ref))
fragment.appendChild(match ? refChipElement(match[1], match[2]) : document.createTextNode(ref))
if (index < refs.length - 1) {
fragment.appendChild(document.createTextNode(' '))
@@ -376,7 +374,7 @@ export function ChatBar({
selection?.addRange(nextRange)
} else {
const current = composerPlainText(editor)
editor.innerHTML = composerHtml(`${current}${current && !/\s$/.test(current) ? ' ' : ''}${inline} `)
renderComposerContents(editor, `${current}${current && !/\s$/.test(current) ? ' ' : ''}${inline} `)
placeCaretEnd(editor)
}
@@ -544,7 +542,7 @@ export function ChatBar({
// No usable caret range — replace from the end of the draft instead.
if (!sel || !range || node?.nodeType !== Node.TEXT_NODE || offset < trigger.tokenLength) {
const current = composerPlainText(editor)
editor.innerHTML = composerHtml(`${current.slice(0, Math.max(0, current.length - trigger.tokenLength))}${text}`)
renderComposerContents(editor, `${current.slice(0, Math.max(0, current.length - trigger.tokenLength))}${text}`)
placeCaretEnd(editor)
return finish()
@@ -556,24 +554,19 @@ export function ChatBar({
replaceRange.deleteContents()
if (directive) {
const holder = document.createElement('span')
holder.innerHTML = refChipHtml(directive[1], directive[2])
const chip = holder.firstChild
const chip = refChipElement(directive[1], directive[2])
const space = document.createTextNode(' ')
const fragment = document.createDocumentFragment()
fragment.append(chip, space)
replaceRange.insertNode(fragment)
if (chip) {
const space = document.createTextNode(' ')
const fragment = document.createDocumentFragment()
fragment.append(chip, space)
replaceRange.insertNode(fragment)
const caret = document.createRange()
caret.setStart(space, 1)
caret.collapse(true)
sel.removeAllRanges()
sel.addRange(caret)
const caret = document.createRange()
caret.setStart(space, 1)
caret.collapse(true)
sel.removeAllRanges()
sel.addRange(caret)
return finish()
}
return finish()
}
document.execCommand('insertText', false, text)
@@ -749,7 +742,7 @@ export function ChatBar({
draftRef.current = ''
if (editorRef.current) {
editorRef.current.innerHTML = ''
editorRef.current.replaceChildren()
}
}
@@ -0,0 +1,18 @@
import { describe, expect, it } from 'vitest'
import { composerPlainText, renderComposerContents, RICH_INPUT_SLOT } from './rich-editor'
describe('renderComposerContents', () => {
it('renders refs and raw text without interpreting user text as HTML', () => {
const editor = document.createElement('div')
editor.dataset.slot = RICH_INPUT_SLOT
renderComposerContents(editor, '@file:`<img src=x onerror=alert(1)>` <b>raw</b>')
expect(editor.querySelector('img')).toBeNull()
expect(editor.querySelector('b')).toBeNull()
expect(editor.textContent).toContain('<img src=x onerror=alert(1)>')
expect(editor.textContent).toContain('<b>raw</b>')
expect(composerPlainText(editor)).toBe('@file:`<img src=x onerror=alert(1)>` <b>raw</b>')
})
})
@@ -6,7 +6,12 @@
* fence — without that, typing after a chip would get re-absorbed on the next
* plain-text round-trip.
*/
import { DIRECTIVE_CHIP_CLASS, directiveIconSvg, formatRefValue } from '@/components/assistant-ui/directive-text'
import {
DIRECTIVE_CHIP_CLASS,
directiveIconElement,
directiveIconSvg,
formatRefValue
} from '@/components/assistant-ui/directive-text'
export const RICH_INPUT_SLOT = 'composer-rich-input'
@@ -51,7 +56,59 @@ export function refChipHtml(kind: string, rawValue: string) {
const id = unquoteRef(rawValue)
const text = `@${kind}:${quoteRefValue(id)}`
return `<span contenteditable="false" data-ref-text="${escapeHtml(text)}" data-ref-id="${escapeHtml(id)}" data-ref-kind="${kind}" class="${DIRECTIVE_CHIP_CLASS}">${directiveIconSvg(kind)}<span class="truncate">${escapeHtml(refLabel(id))}</span></span>`
return `<span contenteditable="false" data-ref-text="${escapeHtml(text)}" data-ref-id="${escapeHtml(id)}" data-ref-kind="${escapeHtml(kind)}" class="${DIRECTIVE_CHIP_CLASS}">${directiveIconSvg(kind)}<span class="truncate">${escapeHtml(refLabel(id))}</span></span>`
}
export function refChipElement(kind: string, rawValue: string) {
const id = unquoteRef(rawValue)
const text = `@${kind}:${quoteRefValue(id)}`
const chip = document.createElement('span')
const label = document.createElement('span')
chip.contentEditable = 'false'
chip.dataset.refText = text
chip.dataset.refId = id
chip.dataset.refKind = kind
chip.className = DIRECTIVE_CHIP_CLASS
label.className = 'truncate'
label.textContent = refLabel(id)
chip.append(directiveIconElement(kind), label)
return chip
}
function appendTextWithBreaks(target: DocumentFragment | HTMLElement, text: string) {
const lines = text.split('\n')
lines.forEach((line, index) => {
if (index > 0) {
target.append(document.createElement('br'))
}
if (line) {
target.append(document.createTextNode(line))
}
})
}
export function appendComposerContents(target: DocumentFragment | HTMLElement, text: string) {
let cursor = 0
REF_RE.lastIndex = 0
for (const match of text.matchAll(REF_RE)) {
const index = match.index ?? 0
appendTextWithBreaks(target, text.slice(cursor, index))
target.append(refChipElement(match[1] || 'file', match[2] || ''))
cursor = index + match[0].length
}
appendTextWithBreaks(target, text.slice(cursor))
}
export function renderComposerContents(target: HTMLElement, text: string) {
target.replaceChildren()
appendComposerContents(target, text)
}
/** Serialize a draft string into chip-HTML for the contenteditable surface. */