Compare commits

..

4 Commits

Author SHA1 Message Date
Haileyesus
cccc1ad268 fix(chat): restrict thinking prefix to claude 2026-06-04 16:35:13 +03:00
Haileyesus
c825d342b3 fix(chat): persist thinking mode selection
Initialize the composer thinking mode from localStorage so reloads keep the user's

selected mode instead of falling back to Standard.

Keep the chosen mode after sending because the selector behaves like a

preference, not a one-shot modifier for a single prompt.
2026-06-04 16:15:02 +03:00
Haile
d9e9df183f fix: plugin svg icon sanitization (#817)
* fix(security)(components): unsanitized svg content injected via `dangerouslys

The plugin icon renderer fetches SVG text from `/api/plugins/.../assets/...` and injects it directly into the DOM using `dangerouslySetInnerHTML` after only checking that the payload starts with `<svg`. This does not remove malicious attributes/elements (e.g., event handlers, scriptable SVG payloads), enabling DOM-based XSS if a plugin asset is malicious or compromised.

Affected files: PluginIcon.tsx

Signed-off-by: tuanaiseo <221258316+tuanaiseo@users.noreply.github.com>

* fix: sanitize plugin svg icons

---------

Signed-off-by: tuanaiseo <221258316+tuanaiseo@users.noreply.github.com>
Co-authored-by: tuanaiseo <tuanaiseo@gmail.com>
Co-authored-by: Simos Mikelatos <simosmik@gmail.com>
2026-06-02 13:24:38 +02:00
Haile
43c33d5cb1 fix: recognize claude auth token env (#818) 2026-06-02 13:23:30 +02:00
4 changed files with 89 additions and 11 deletions

17
package-lock.json generated
View File

@@ -39,6 +39,7 @@
"cmdk": "^1.1.1", "cmdk": "^1.1.1",
"cors": "^2.8.5", "cors": "^2.8.5",
"cross-spawn": "^7.0.3", "cross-spawn": "^7.0.3",
"dompurify": "^3.4.7",
"express": "^4.18.2", "express": "^4.18.2",
"fuse.js": "^7.0.0", "fuse.js": "^7.0.0",
"gray-matter": "^4.0.3", "gray-matter": "^4.0.3",
@@ -4580,6 +4581,13 @@
"@types/node": "*" "@types/node": "*"
} }
}, },
"node_modules/@types/trusted-types": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"license": "MIT",
"optional": true
},
"node_modules/@types/unist": { "node_modules/@types/unist": {
"version": "3.0.3", "version": "3.0.3",
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz",
@@ -7485,6 +7493,15 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/dompurify": {
"version": "3.4.7",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.7.tgz",
"integrity": "sha512-2jBxDJY4RR06tQNy4w5FlFH7kfxsQZlufd0sbv+chfHCxeJwrFw2baUDsSwvBISD4K4RDbd0PTfy3uNXsR6siA==",
"license": "(MPL-2.0 OR Apache-2.0)",
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
}
},
"node_modules/dot-prop": { "node_modules/dot-prop": {
"version": "5.3.0", "version": "5.3.0",
"resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-5.3.0.tgz", "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-5.3.0.tgz",

View File

@@ -96,6 +96,7 @@
"cmdk": "^1.1.1", "cmdk": "^1.1.1",
"cors": "^2.8.5", "cors": "^2.8.5",
"cross-spawn": "^7.0.3", "cross-spawn": "^7.0.3",
"dompurify": "^3.4.7",
"express": "^4.18.2", "express": "^4.18.2",
"fuse.js": "^7.0.0", "fuse.js": "^7.0.0",
"gray-matter": "^4.0.3", "gray-matter": "^4.0.3",

View File

@@ -143,6 +143,21 @@ const createFakeSubmitEvent = () => {
return { preventDefault: () => undefined } as unknown as FormEvent<HTMLFormElement>; return { preventDefault: () => undefined } as unknown as FormEvent<HTMLFormElement>;
}; };
const THINKING_MODE_STORAGE_KEY = 'chat-thinking-mode';
const getInitialThinkingMode = () => {
if (typeof window === 'undefined') {
return 'none';
}
const savedMode = safeLocalStorage.getItem(THINKING_MODE_STORAGE_KEY);
if (!savedMode) {
return 'none';
}
return thinkingModes.some((mode) => mode.id === savedMode) ? savedMode : 'none';
};
const getNotificationSessionSummary = ( const getNotificationSessionSummary = (
selectedSession: ProjectSession | null, selectedSession: ProjectSession | null,
fallbackInput: string, fallbackInput: string,
@@ -204,7 +219,7 @@ export function useChatComposerState({
const [uploadingImages, setUploadingImages] = useState<Map<string, number>>(new Map()); const [uploadingImages, setUploadingImages] = useState<Map<string, number>>(new Map());
const [imageErrors, setImageErrors] = useState<Map<string, string>>(new Map()); const [imageErrors, setImageErrors] = useState<Map<string, string>>(new Map());
const [isTextareaExpanded, setIsTextareaExpanded] = useState(false); const [isTextareaExpanded, setIsTextareaExpanded] = useState(false);
const [thinkingMode, setThinkingMode] = useState('none'); const [thinkingMode, setThinkingMode] = useState(getInitialThinkingMode);
const [commandModalPayload, setCommandModalPayload] = useState<CommandModalPayload | null>(null); const [commandModalPayload, setCommandModalPayload] = useState<CommandModalPayload | null>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null); const textareaRef = useRef<HTMLTextAreaElement>(null);
@@ -564,7 +579,7 @@ export function useChatComposerState({
let messageContent = currentInput; let messageContent = currentInput;
const selectedThinkingMode = thinkingModes.find((mode: { id: string; prefix?: string }) => mode.id === thinkingMode); const selectedThinkingMode = thinkingModes.find((mode: { id: string; prefix?: string }) => mode.id === thinkingMode);
if (selectedThinkingMode && selectedThinkingMode.prefix) { if (provider === 'claude' && selectedThinkingMode && selectedThinkingMode.prefix) {
messageContent = `${selectedThinkingMode.prefix}: ${currentInput}`; messageContent = `${selectedThinkingMode.prefix}: ${currentInput}`;
} }
@@ -749,7 +764,6 @@ export function useChatComposerState({
setUploadingImages(new Map()); setUploadingImages(new Map());
setImageErrors(new Map()); setImageErrors(new Map());
setIsTextareaExpanded(false); setIsTextareaExpanded(false);
setThinkingMode('none');
if (textareaRef.current) { if (textareaRef.current) {
textareaRef.current.style.height = 'auto'; textareaRef.current.style.height = 'auto';
@@ -795,6 +809,10 @@ export function useChatComposerState({
inputValueRef.current = input; inputValueRef.current = input;
}, [input]); }, [input]);
useEffect(() => {
safeLocalStorage.setItem(THINKING_MODE_STORAGE_KEY, thinkingMode);
}, [thinkingMode]);
useEffect(() => { useEffect(() => {
if (!selectedProjectId) { if (!selectedProjectId) {
return; return;

View File

@@ -1,4 +1,6 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import DOMPurify from 'dompurify';
import { authenticatedFetch } from '../../../utils/api'; import { authenticatedFetch } from '../../../utils/api';
type Props = { type Props = {
@@ -10,6 +12,48 @@ type Props = {
// Module-level cache so repeated renders don't re-fetch // Module-level cache so repeated renders don't re-fetch
const svgCache = new Map<string, string>(); const svgCache = new Map<string, string>();
const FORBIDDEN_SVG_TAGS = [
'script',
'foreignObject',
'iframe',
'object',
'embed',
'link',
'meta',
'style',
'animate',
'set',
'animateTransform',
'animateMotion',
];
const FORBIDDEN_SVG_ATTRS = [
'href',
'xlink:href',
'src',
'style',
];
function sanitizeSvg(svgText: string): string | null {
const sanitized = DOMPurify.sanitize(svgText, {
USE_PROFILES: { svg: true, svgFilters: true },
FORBID_TAGS: FORBIDDEN_SVG_TAGS,
FORBID_ATTR: FORBIDDEN_SVG_ATTRS,
});
if (!sanitized) return null;
try {
const doc = new DOMParser().parseFromString(sanitized, 'image/svg+xml');
const root = doc.documentElement;
if (!root || root.nodeName.toLowerCase() !== 'svg') return null;
if (doc.querySelector('parsererror')) return null;
return sanitized;
} catch {
return null;
}
}
export default function PluginIcon({ pluginName, iconFile, className }: Props) { export default function PluginIcon({ pluginName, iconFile, className }: Props) {
const url = iconFile const url = iconFile
? `/api/plugins/${encodeURIComponent(pluginName)}/assets/${encodeURIComponent(iconFile)}` ? `/api/plugins/${encodeURIComponent(pluginName)}/assets/${encodeURIComponent(iconFile)}`
@@ -24,9 +68,11 @@ export default function PluginIcon({ pluginName, iconFile, className }: Props) {
return r.text(); return r.text();
}) })
.then((text) => { .then((text) => {
if (text && text.trimStart().startsWith('<svg')) { if (!text) return;
svgCache.set(url, text); const sanitized = sanitizeSvg(text);
setSvg(text); if (sanitized) {
svgCache.set(url, sanitized);
setSvg(sanitized);
} }
}) })
.catch(() => {}); .catch(() => {});
@@ -35,10 +81,6 @@ export default function PluginIcon({ pluginName, iconFile, className }: Props) {
if (!svg) return <span className={className} />; if (!svg) return <span className={className} />;
return ( return (
<span <span className={className} dangerouslySetInnerHTML={{ __html: svg }} />
className={className}
// SVG is fetched from the user's own installed plugin — same trust level as the plugin code itself
dangerouslySetInnerHTML={{ __html: svg }}
/>
); );
} }