mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-10 08:32:09 +00:00
fix(desktop): address security scan findings
This commit is contained in:
parent
023730314b
commit
301c698491
6 changed files with 151 additions and 28 deletions
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
18
apps/desktop/src/app/chat/composer/rich-editor.test.ts
Normal file
18
apps/desktop/src/app/chat/composer/rich-editor.test.ts
Normal file
|
|
@ -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. */
|
||||
|
|
|
|||
23
apps/desktop/src/app/settings/helpers.test.ts
Normal file
23
apps/desktop/src/app/settings/helpers.test.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { HermesConfigRecord } from '@/types/hermes'
|
||||
|
||||
import { getNested, setNested } from './helpers'
|
||||
|
||||
describe('settings helpers', () => {
|
||||
it('reads and writes nested config paths', () => {
|
||||
const config: HermesConfigRecord = { display: { theme: 'mono' } }
|
||||
const next = setNested(config, 'display.theme', 'slate')
|
||||
|
||||
expect(getNested(next, 'display.theme')).toBe('slate')
|
||||
expect(getNested(config, 'display.theme')).toBe('mono')
|
||||
})
|
||||
|
||||
it('rejects prototype-polluting config paths', () => {
|
||||
const config: HermesConfigRecord = {}
|
||||
|
||||
expect(() => setNested(config, '__proto__.polluted', true)).toThrow('Unsafe config path')
|
||||
expect(() => setNested(config, 'constructor.prototype.polluted', true)).toThrow('Unsafe config path')
|
||||
expect(({} as Record<string, unknown>).polluted).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
|
@ -23,10 +23,22 @@ export const providerGroup = (key: string) => PROVIDER_GROUPS.find(g => key.star
|
|||
|
||||
export const providerPriority = (name: string) => PROVIDER_GROUPS.find(g => g.name === name)?.priority ?? 99
|
||||
|
||||
const POLLUTING_PATH_PARTS = new Set(['__proto__', 'constructor', 'prototype'])
|
||||
|
||||
function configPathParts(path: string): string[] {
|
||||
const parts = path.split('.')
|
||||
|
||||
if (parts.some(part => !part || POLLUTING_PATH_PARTS.has(part))) {
|
||||
throw new Error(`Unsafe config path: ${path}`)
|
||||
}
|
||||
|
||||
return parts
|
||||
}
|
||||
|
||||
export function getNested(obj: HermesConfigRecord, path: string): unknown {
|
||||
let cur: unknown = obj
|
||||
|
||||
for (const part of path.split('.')) {
|
||||
for (const part of configPathParts(path)) {
|
||||
if (cur == null || typeof cur !== 'object') {
|
||||
return undefined
|
||||
}
|
||||
|
|
@ -39,7 +51,7 @@ export function getNested(obj: HermesConfigRecord, path: string): unknown {
|
|||
|
||||
export function setNested(obj: HermesConfigRecord, path: string, value: unknown): HermesConfigRecord {
|
||||
const clone = structuredClone(obj)
|
||||
const parts = path.split('.')
|
||||
const parts = configPathParts(path)
|
||||
let cur: Record<string, unknown> = clone
|
||||
|
||||
for (let i = 0; i < parts.length - 1; i += 1) {
|
||||
|
|
|
|||
|
|
@ -58,6 +58,26 @@ export function directiveIconSvg(type: string) {
|
|||
return `<svg ${SVG_ATTRS} class="${ICON_CLASS}">${inner}</svg>`
|
||||
}
|
||||
|
||||
export function directiveIconElement(type: string) {
|
||||
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
|
||||
svg.setAttribute('class', ICON_CLASS)
|
||||
svg.setAttribute('fill', 'none')
|
||||
svg.setAttribute('stroke', 'currentColor')
|
||||
svg.setAttribute('stroke-linecap', 'round')
|
||||
svg.setAttribute('stroke-linejoin', 'round')
|
||||
svg.setAttribute('stroke-width', '2')
|
||||
svg.setAttribute('viewBox', '0 0 24 24')
|
||||
svg.setAttribute('xmlns', 'http://www.w3.org/2000/svg')
|
||||
|
||||
for (const d of iconPathsFor(type)) {
|
||||
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path')
|
||||
path.setAttribute('d', d)
|
||||
svg.append(path)
|
||||
}
|
||||
|
||||
return svg
|
||||
}
|
||||
|
||||
const DirectiveIcon: FC<{ type: string }> = ({ type }) => (
|
||||
<svg
|
||||
className={ICON_CLASS}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue