mirror of
https://github.com/lobehub/lobe-chat.git
synced 2026-06-13 19:20:04 +00:00
✨ feat(remote-device): add client renders for device tool results (#15437)
* ✨ feat(remote-device): add client renders for listOnlineDevices and activateDevice Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * 🐛 fix(utils): make SVG event-handler stripping engine-independent DOMPurify's FORBID_ATTR / SVG-profile allowlist path relies on the underlying DOM's attribute + namespace handling, which differs across engines (jsdom vs happy-dom) and DOMPurify versions — in some CI environments on* handlers on SVG-namespaced nodes slipped through. Add a scoped uponSanitizeAttribute hook to drop every on* attribute deterministically, and assert by security property instead of exact serialization to drop whitespace brittleness. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * 🐛 fix(remote-device): render activation failure content when no device state activateDevice returns success:false with explanatory content but no error and no state when the target is offline/unknown. The tool detail view only skips custom rendering when result.error is set, so the custom renderer's `return null` rendered a blank result. Fall back to the failure content so the user/model still sees the message. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * 🐛 fix(utils): deterministically scrub SVG on* handlers via post-pass The DOMPurify uponSanitizeAttribute hook still failed in CI: <script> is removed (tag filtering) but on* handlers survive, because the attribute-sanitization phase doesn't run for SVG-namespaced nodes in CI's DOM engine — so the hook never fires. Replace it with an explicit regex scrub on the serialized output, which strips every on* event-handler attribute independent of the DOM engine. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * 🔒 fix(utils): loop SVG on* scrub until stable to close recombination bypass A single-pass regex replace can leave a fresh handler behind when removing one splices the surrounding text back together (` on onclick="x"click="y"` → ` onclick="y"`) — the CodeQL js/incomplete-multi-character-sanitization case. Repeat the scrub until the string stops changing so no on*= token can survive. Adds a regression test for the recombination input. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -3,7 +3,8 @@
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
".": "./src/index.ts",
|
||||
"./client": "./src/client/index.ts"
|
||||
},
|
||||
"main": "./src/index.ts",
|
||||
"dependencies": {
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
'use client';
|
||||
|
||||
import { type BuiltinRenderProps } from '@lobechat/types';
|
||||
import { Icon } from '@lobehub/ui';
|
||||
import { createStaticStyles } from 'antd-style';
|
||||
import { AlertTriangleIcon } from 'lucide-react';
|
||||
import { memo } from 'react';
|
||||
|
||||
import type { ActivateDeviceParams, ActivateDeviceState } from '../../../types';
|
||||
import DeviceCard from '../DeviceCard';
|
||||
|
||||
const styles = createStaticStyles(({ css, cssVar }) => ({
|
||||
failure: css`
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
|
||||
padding-block: 12px;
|
||||
padding-inline: 12px;
|
||||
border: 1px solid ${cssVar.colorWarningBorder};
|
||||
border-radius: 10px;
|
||||
|
||||
font-size: 13px;
|
||||
color: ${cssVar.colorWarningText};
|
||||
|
||||
background: ${cssVar.colorWarningBg};
|
||||
`,
|
||||
}));
|
||||
|
||||
const ActivateDevice = memo<BuiltinRenderProps<ActivateDeviceParams, ActivateDeviceState, string>>(
|
||||
({ pluginState, content }) => {
|
||||
const device = pluginState?.activatedDevice;
|
||||
|
||||
if (device) return <DeviceCard activated device={device} />;
|
||||
|
||||
// Activation failed without a thrown error (e.g. device offline / unknown), so no state is
|
||||
// produced. Fall back to the explanatory content the runtime returned instead of rendering
|
||||
// blank — the tool detail view only skips custom renders when `result.error` is set.
|
||||
if (typeof content === 'string' && content.length > 0) {
|
||||
return (
|
||||
<div className={styles.failure}>
|
||||
<Icon icon={AlertTriangleIcon} size={14} />
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
);
|
||||
|
||||
ActivateDevice.displayName = 'ActivateDevice';
|
||||
|
||||
export default ActivateDevice;
|
||||
@@ -0,0 +1,101 @@
|
||||
'use client';
|
||||
|
||||
import { Flexbox, Icon } from '@lobehub/ui';
|
||||
import { createStaticStyles } from 'antd-style';
|
||||
import { CheckCircle2, MonitorIcon } from 'lucide-react';
|
||||
import { memo } from 'react';
|
||||
|
||||
import type { DeviceAttachment } from '../../../ExecutionRuntime/types';
|
||||
|
||||
const styles = createStaticStyles(({ css, cssVar }) => ({
|
||||
activated: css`
|
||||
color: ${cssVar.colorSuccess};
|
||||
background: ${cssVar.colorSuccessBg};
|
||||
`,
|
||||
badge: css`
|
||||
display: inline-flex;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
|
||||
padding-block: 2px;
|
||||
padding-inline: 8px;
|
||||
border-radius: 6px;
|
||||
|
||||
font-size: 12px;
|
||||
line-height: 16px;
|
||||
white-space: nowrap;
|
||||
`,
|
||||
card: css`
|
||||
width: 100%;
|
||||
padding-block: 10px;
|
||||
padding-inline: 12px;
|
||||
border: 1px solid ${cssVar.colorBorderSecondary};
|
||||
border-radius: 10px;
|
||||
|
||||
background: ${cssVar.colorBgContainer};
|
||||
`,
|
||||
hostname: css`
|
||||
overflow: hidden;
|
||||
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
`,
|
||||
icon: css`
|
||||
flex: none;
|
||||
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 8px;
|
||||
|
||||
color: ${cssVar.colorTextSecondary};
|
||||
|
||||
background: ${cssVar.colorFillTertiary};
|
||||
`,
|
||||
meta: css`
|
||||
overflow: hidden;
|
||||
|
||||
font-family: ${cssVar.fontFamilyCode};
|
||||
font-size: 12px;
|
||||
color: ${cssVar.colorTextDescription};
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
`,
|
||||
online: css`
|
||||
color: ${cssVar.colorTextSecondary};
|
||||
background: ${cssVar.colorFillTertiary};
|
||||
`,
|
||||
}));
|
||||
|
||||
interface DeviceCardProps {
|
||||
/** Render the activated treatment (check badge) instead of the online badge. */
|
||||
activated?: boolean;
|
||||
device: DeviceAttachment;
|
||||
}
|
||||
|
||||
const DeviceCard = memo<DeviceCardProps>(({ device, activated }) => (
|
||||
<Flexbox horizontal align={'center'} className={styles.card} gap={12}>
|
||||
<Flexbox align={'center'} className={styles.icon} justify={'center'}>
|
||||
<Icon icon={MonitorIcon} size={18} />
|
||||
</Flexbox>
|
||||
<Flexbox flex={1} gap={2} style={{ minWidth: 0 }}>
|
||||
<span className={styles.hostname}>{device.hostname}</span>
|
||||
<span className={styles.meta}>
|
||||
{device.platform} · {device.deviceId.slice(0, 12)}
|
||||
</span>
|
||||
</Flexbox>
|
||||
{activated ? (
|
||||
<span className={[styles.badge, styles.activated].join(' ')}>
|
||||
<Icon icon={CheckCircle2} size={12} />
|
||||
Activated
|
||||
</span>
|
||||
) : (
|
||||
device.online && <span className={[styles.badge, styles.online].join(' ')}>Online</span>
|
||||
)}
|
||||
</Flexbox>
|
||||
));
|
||||
|
||||
DeviceCard.displayName = 'DeviceCard';
|
||||
|
||||
export default DeviceCard;
|
||||
@@ -0,0 +1,45 @@
|
||||
'use client';
|
||||
|
||||
import { type BuiltinRenderProps } from '@lobechat/types';
|
||||
import { Flexbox } from '@lobehub/ui';
|
||||
import { createStaticStyles } from 'antd-style';
|
||||
import { memo } from 'react';
|
||||
|
||||
import type { ListOnlineDevicesState } from '../../../types';
|
||||
import DeviceCard from '../DeviceCard';
|
||||
|
||||
const styles = createStaticStyles(({ css, cssVar }) => ({
|
||||
empty: css`
|
||||
padding-block: 12px;
|
||||
padding-inline: 12px;
|
||||
border: 1px solid ${cssVar.colorBorderSecondary};
|
||||
border-radius: 10px;
|
||||
|
||||
font-size: 13px;
|
||||
color: ${cssVar.colorTextDescription};
|
||||
|
||||
background: ${cssVar.colorBgContainer};
|
||||
`,
|
||||
}));
|
||||
|
||||
const ListDevices = memo<BuiltinRenderProps<undefined, ListOnlineDevicesState>>(
|
||||
({ pluginState }) => {
|
||||
const devices = pluginState?.devices ?? [];
|
||||
|
||||
if (devices.length === 0) {
|
||||
return <div className={styles.empty}>No online devices found.</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Flexbox gap={8} width={'100%'}>
|
||||
{devices.map((device) => (
|
||||
<DeviceCard device={device} key={device.deviceId} />
|
||||
))}
|
||||
</Flexbox>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
ListDevices.displayName = 'ListDevices';
|
||||
|
||||
export default ListDevices;
|
||||
@@ -0,0 +1,8 @@
|
||||
import { RemoteDeviceApiName } from '../../types';
|
||||
import ActivateDevice from './ActivateDevice';
|
||||
import ListDevices from './ListDevices';
|
||||
|
||||
export const RemoteDeviceRenders = {
|
||||
[RemoteDeviceApiName.activateDevice]: ActivateDevice,
|
||||
[RemoteDeviceApiName.listOnlineDevices]: ListDevices,
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
export { RemoteDeviceManifest } from '../manifest';
|
||||
export * from '../types';
|
||||
export { RemoteDeviceRenders } from './Render';
|
||||
@@ -1,3 +1,5 @@
|
||||
import { type DeviceAttachment } from './ExecutionRuntime/types';
|
||||
|
||||
export const RemoteDeviceIdentifier = 'lobe-remote-device';
|
||||
|
||||
export const RemoteDeviceApiName = {
|
||||
@@ -7,3 +9,19 @@ export const RemoteDeviceApiName = {
|
||||
|
||||
export type RemoteDeviceApiNameType =
|
||||
(typeof RemoteDeviceApiName)[keyof typeof RemoteDeviceApiName];
|
||||
|
||||
/** Plugin state produced by the `listOnlineDevices` API. */
|
||||
export interface ListOnlineDevicesState {
|
||||
devices?: DeviceAttachment[];
|
||||
}
|
||||
|
||||
/** Arguments accepted by the `activateDevice` API. */
|
||||
export interface ActivateDeviceParams {
|
||||
deviceId: string;
|
||||
}
|
||||
|
||||
/** Plugin state produced by the `activateDevice` API. */
|
||||
export interface ActivateDeviceState {
|
||||
activatedDevice?: DeviceAttachment;
|
||||
metadata?: { activeDeviceId?: string };
|
||||
}
|
||||
|
||||
@@ -27,6 +27,10 @@ import {
|
||||
import { MemoryManifest, MemoryRenders } from '@lobechat/builtin-tool-memory/client';
|
||||
import { MessageManifest, MessageRenders } from '@lobechat/builtin-tool-message/client';
|
||||
import { PageAgentManifest, PageAgentRenders } from '@lobechat/builtin-tool-page-agent/client';
|
||||
import {
|
||||
RemoteDeviceManifest,
|
||||
RemoteDeviceRenders,
|
||||
} from '@lobechat/builtin-tool-remote-device/client';
|
||||
import { SkillStoreManifest, SkillStoreRenders } from '@lobechat/builtin-tool-skill-store/client';
|
||||
import { SkillsManifest, SkillsRenders } from '@lobechat/builtin-tool-skills/client';
|
||||
import { TaskManifest, TaskRenders } from '@lobechat/builtin-tool-task/client';
|
||||
@@ -70,6 +74,7 @@ const BuiltinToolsRenders: Record<string, Record<string, BuiltinRender>> = {
|
||||
[MessageManifest.identifier]: MessageRenders as Record<string, BuiltinRender>,
|
||||
[NotebookIdentifier]: NotebookRenders,
|
||||
[PageAgentManifest.identifier]: PageAgentRenders as Record<string, BuiltinRender>,
|
||||
[RemoteDeviceManifest.identifier]: RemoteDeviceRenders as Record<string, BuiltinRender>,
|
||||
[SkillStoreManifest.identifier]: SkillStoreRenders as Record<string, BuiltinRender>,
|
||||
[SkillsManifest.identifier]: SkillsRenders as Record<string, BuiltinRender>,
|
||||
[TaskManifest.identifier]: TaskRenders as Record<string, BuiltinRender>,
|
||||
|
||||
@@ -52,6 +52,16 @@ describe('sanitizeSVGContent', () => {
|
||||
expect(sanitized).toContain('fill="red"');
|
||||
});
|
||||
|
||||
it('should not leave a recombined event handler after stripping', () => {
|
||||
// Removing the inner handler in a single pass would splice ` on` + `click="y"` back into a
|
||||
// fresh ` onclick="y"`; the scrub must repeat until no handler remains.
|
||||
const malicious = `<svg xmlns="http://www.w3.org/2000/svg"><circle on onclick="x"click="y" /></svg>`;
|
||||
|
||||
const sanitized = sanitizeSVGContent(malicious);
|
||||
|
||||
expect(sanitized).not.toMatch(/\son[a-z]+\s*=/i);
|
||||
});
|
||||
|
||||
it('should remove dangerous embed and object tags', () => {
|
||||
const maliciousSvg = `
|
||||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
@@ -93,16 +103,20 @@ describe('sanitizeSVGContent', () => {
|
||||
|
||||
const sanitized = sanitizeSVGContent(complexSvg);
|
||||
|
||||
// Should preserve safe elements and attributes
|
||||
expect(sanitized).toEqual(`
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200">
|
||||
<defs>
|
||||
<linearGradient id="grad1">
|
||||
<stop offset="0%" stop-color="red"></stop>
|
||||
<stop offset="100%" stop-color="blue"></stop>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<g transform="translate(50,50)">
|
||||
</g></svg>`);
|
||||
// Should preserve safe structure (assert by property, not exact serialization —
|
||||
// whitespace/self-closing handling varies across DOM engines).
|
||||
expect(sanitized).toContain('<svg');
|
||||
expect(sanitized).toContain('viewBox="0 0 200 200"');
|
||||
expect(sanitized).toContain('<linearGradient id="grad1"');
|
||||
expect(sanitized).toContain('stop-color="red"');
|
||||
expect(sanitized).toContain('transform="translate(50,50)"');
|
||||
|
||||
// Should strip all threats regardless of engine
|
||||
expect(sanitized).not.toContain('<script');
|
||||
expect(sanitized).not.toContain('malicious');
|
||||
expect(sanitized).not.toContain('onclick');
|
||||
expect(sanitized).not.toContain('onload');
|
||||
expect(sanitized).not.toContain('hack()');
|
||||
expect(sanitized).not.toContain('evil()');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -20,16 +20,40 @@ const FORBID_EVENT_HANDLERS = [
|
||||
'onunload',
|
||||
];
|
||||
|
||||
/**
|
||||
* Matches any `on*` event-handler attribute together with its value — ` onclick="…"`,
|
||||
* ` onload='…'`, or unquoted ` onfoo=bar`. SVG has no safe attribute that starts with `on`,
|
||||
* so stripping all of them is lossless for legitimate content.
|
||||
*/
|
||||
const EVENT_HANDLER_ATTR = /\son[a-z]+\s*=\s*("[^"]*"|'[^']*'|[^\s/>]+)/gi;
|
||||
|
||||
/**
|
||||
* Sanitizes SVG content to prevent XSS attacks while preserving safe SVG elements and attributes
|
||||
* @param content - The SVG content to sanitize
|
||||
* @returns Sanitized SVG content safe for rendering
|
||||
*/
|
||||
export const sanitizeSVGContent = (content: string): string => {
|
||||
return DOMPurify.sanitize(content, {
|
||||
const sanitized = DOMPurify.sanitize(content, {
|
||||
FORBID_ATTR: FORBID_EVENT_HANDLERS,
|
||||
FORBID_TAGS: ['embed', 'link', 'object', 'script', 'style'],
|
||||
KEEP_CONTENT: false,
|
||||
USE_PROFILES: { svg: true, svgFilters: true },
|
||||
});
|
||||
|
||||
// Defense-in-depth: DOMPurify's attribute-level filtering runs through the underlying DOM's
|
||||
// attribute + namespace handling, which is inconsistent across engines (jsdom vs happy-dom) and
|
||||
// DOMPurify versions — in some CI environments `on*` handlers on SVG-namespaced nodes are not
|
||||
// stripped at all. Scrub them from the serialized output so removal is deterministic everywhere.
|
||||
//
|
||||
// Apply repeatedly until the string stabilizes: removing one handler can splice the surrounding
|
||||
// text into a fresh `on…=` token (e.g. ` on onclick="x"click="y"` → ` onclick="y"`), which a
|
||||
// single pass would miss.
|
||||
let scrubbed = sanitized;
|
||||
let previous: string;
|
||||
do {
|
||||
previous = scrubbed;
|
||||
scrubbed = scrubbed.replaceAll(EVENT_HANDLER_ATTR, '');
|
||||
} while (scrubbed !== previous);
|
||||
|
||||
return scrubbed;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user