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);
// Prevent path traversal — resolved path must be within plugin directory
if (!resolved.startsWith(pluginDir + path.sep) && resolved !== pluginDir) {
// Prevent path traversal — canonicalize via realpath to defeat symlink bypasses
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;
}
if (!fs.existsSync(resolved)) return null;
return resolved;
return realResolved;
}
export function installPluginFromGit(url) {

View File

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

View File

@@ -60,7 +60,7 @@ export default function PluginTabContent({
}, [isDarkMode, selectedProject, selectedSession]);
useEffect(() => {
if (!containerRef.current) return;
if (!containerRef.current || !plugin?.enabled) return;
let active = true;
const container = containerRef.current;
@@ -120,7 +120,7 @@ export default function PluginTabContent({
contextCallbacksRef.current.clear();
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" />;
}

View File

@@ -98,7 +98,7 @@ type CodexSettingsStorage = {
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 => {
// 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: 'api', labelKey: 'mainTabs.apiTokens', icon: Key },
{ 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) {

View File

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

View File

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

View File

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

View File

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