fix(plugins): harden path traversal and respect enabled state

Use realpathSync to canonicalize paths before the plugin asset
boundary check, preventing symlink-based traversal bypasses that
could escape the plugin directory.

PluginTabContent now guards on plugin.enabled before mounting the
plugin module, and re-mounts when the enabled state changes so
toggling a plugin takes effect without a page reload.

PluginIcon safely handles a missing iconFile prop and skips
processing non-OK fetch responses instead of attempting to parse
error bodies as SVG.

Register 'plugins' as a known main tab so the settings router
preserves the tab on navigation.
This commit is contained in:
simosmik
2026-03-09 06:49:51 +00:00
parent a7e8b12ef4
commit efdee162c9
9 changed files with 29 additions and 18 deletions

View File

@@ -143,14 +143,16 @@ export function resolvePluginAssetPath(name, assetPath) {
const resolved = path.resolve(pluginDir, assetPath); const resolved = path.resolve(pluginDir, assetPath);
// Prevent path traversal — resolved path must be within plugin directory // Prevent path traversal — canonicalize via realpath to defeat symlink bypasses
if (!resolved.startsWith(pluginDir + path.sep) && resolved !== pluginDir) { if (!fs.existsSync(resolved)) return null;
const realResolved = fs.realpathSync(resolved);
const realPluginDir = fs.realpathSync(pluginDir);
if (!realResolved.startsWith(realPluginDir + path.sep) && realResolved !== realPluginDir) {
return null; return null;
} }
if (!fs.existsSync(resolved)) return null; return realResolved;
return resolved;
} }
export function installPluginFromGit(url) { export function installPluginFromGit(url) {

View File

@@ -11,15 +11,20 @@ type Props = {
const svgCache = new Map<string, string>(); const svgCache = new Map<string, string>();
export default function PluginIcon({ pluginName, iconFile, className }: Props) { export default function PluginIcon({ pluginName, iconFile, className }: Props) {
const url = `/api/plugins/${encodeURIComponent(pluginName)}/assets/${encodeURIComponent(iconFile)}`; const url = iconFile
const [svg, setSvg] = useState<string | null>(svgCache.get(url) ?? null); ? `/api/plugins/${encodeURIComponent(pluginName)}/assets/${encodeURIComponent(iconFile)}`
: '';
const [svg, setSvg] = useState<string | null>(url ? (svgCache.get(url) ?? null) : null);
useEffect(() => { useEffect(() => {
if (svgCache.has(url)) return; if (!url || svgCache.has(url)) return;
authenticatedFetch(url) authenticatedFetch(url)
.then((r) => r.text()) .then((r) => {
if (!r.ok) return;
return r.text();
})
.then((text) => { .then((text) => {
if (text.trimStart().startsWith('<svg')) { if (text && text.trimStart().startsWith('<svg')) {
svgCache.set(url, text); svgCache.set(url, text);
setSvg(text); setSvg(text);
} }

View File

@@ -60,7 +60,7 @@ export default function PluginTabContent({
}, [isDarkMode, selectedProject, selectedSession]); }, [isDarkMode, selectedProject, selectedSession]);
useEffect(() => { useEffect(() => {
if (!containerRef.current) return; if (!containerRef.current || !plugin?.enabled) return;
let active = true; let active = true;
const container = containerRef.current; const container = containerRef.current;
@@ -120,7 +120,7 @@ export default function PluginTabContent({
contextCallbacksRef.current.clear(); contextCallbacksRef.current.clear();
moduleRef.current = null; moduleRef.current = null;
}; };
}, [pluginName, plugin?.entry]); // re-mount only when the plugin itself changes }, [pluginName, plugin?.entry, plugin?.enabled]); // re-mount when plugin or enabled state changes
return <div ref={containerRef} className="h-full w-full overflow-auto" />; return <div ref={containerRef} className="h-full w-full overflow-auto" />;
} }

View File

@@ -98,7 +98,7 @@ type CodexSettingsStorage = {
type ActiveLoginProvider = AgentProvider | ''; type ActiveLoginProvider = AgentProvider | '';
const KNOWN_MAIN_TABS: SettingsMainTab[] = ['agents', 'appearance', 'git', 'api', 'tasks']; const KNOWN_MAIN_TABS: SettingsMainTab[] = ['agents', 'appearance', 'git', 'api', 'tasks', 'plugins'];
const normalizeMainTab = (tab: string): SettingsMainTab => { const normalizeMainTab = (tab: string): SettingsMainTab => {
// Keep backwards compatibility with older callers that still pass "tools". // Keep backwards compatibility with older callers that still pass "tools".

View File

@@ -20,7 +20,7 @@ const TAB_CONFIG: MainTabConfig[] = [
{ id: 'git', labelKey: 'mainTabs.git', icon: GitBranch }, { id: 'git', labelKey: 'mainTabs.git', icon: GitBranch },
{ id: 'api', labelKey: 'mainTabs.apiTokens', icon: Key }, { id: 'api', labelKey: 'mainTabs.apiTokens', icon: Key },
{ id: 'tasks', labelKey: 'mainTabs.tasks' }, { id: 'tasks', labelKey: 'mainTabs.tasks' },
{ id: 'plugins', label: 'Plugins', icon: Puzzle }, { id: 'plugins', labelKey: 'mainTabs.plugins', icon: Puzzle },
]; ];
export default function SettingsMainTabs({ activeTab, onChange }: SettingsMainTabsProps) { export default function SettingsMainTabs({ activeTab, onChange }: SettingsMainTabsProps) {

View File

@@ -104,7 +104,8 @@
"appearance": "Appearance", "appearance": "Appearance",
"git": "Git", "git": "Git",
"apiTokens": "API & Tokens", "apiTokens": "API & Tokens",
"tasks": "Tasks" "tasks": "Tasks",
"plugins": "Plugins"
}, },
"appearanceSettings": { "appearanceSettings": {
"darkMode": { "darkMode": {

View File

@@ -104,7 +104,8 @@
"appearance": "外観", "appearance": "外観",
"git": "Git", "git": "Git",
"apiTokens": "API & トークン", "apiTokens": "API & トークン",
"tasks": "タスク" "tasks": "タスク",
"plugins": "プラグイン"
}, },
"appearanceSettings": { "appearanceSettings": {
"darkMode": { "darkMode": {

View File

@@ -104,7 +104,8 @@
"appearance": "외관", "appearance": "외관",
"git": "Git", "git": "Git",
"apiTokens": "API & 토큰", "apiTokens": "API & 토큰",
"tasks": "작업" "tasks": "작업",
"plugins": "플러그인"
}, },
"appearanceSettings": { "appearanceSettings": {
"darkMode": { "darkMode": {

View File

@@ -104,7 +104,8 @@
"appearance": "外观", "appearance": "外观",
"git": "Git", "git": "Git",
"apiTokens": "API 和令牌", "apiTokens": "API 和令牌",
"tasks": "任务" "tasks": "任务",
"plugins": "插件"
}, },
"appearanceSettings": { "appearanceSettings": {
"darkMode": { "darkMode": {