From 68d5540985958c0349df1becfc47f12642296237 Mon Sep 17 00:00:00 2001 From: Chuck Date: Sat, 11 Jul 2026 12:38:16 -0400 Subject: [PATCH] fix(dev-server): lexical containment check on resolved plugin dirs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeQL doesn't recognize the interprocedural allowlist as a path-injection barrier; add the canonical one — normalize (without following symlinks, since dev plugins are commonly symlinked into plugins/) and require the result to stay inside the search dir. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01FqzC1nzTWL4kaqgMaQZFam --- scripts/dev_server.py | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/scripts/dev_server.py b/scripts/dev_server.py index b3a450a9..70f1fcf8 100644 --- a/scripts/dev_server.py +++ b/scripts/dev_server.py @@ -113,8 +113,10 @@ def discover_plugins() -> List[Dict[str, Any]]: def find_plugin_dir(plugin_id: str) -> Optional[Path]: """Find a plugin directory by ID. - plugin_id comes from request input; the allowlist match keeps it from - ever naming a path outside the plugin search dirs. + plugin_id comes from request input: it must pass an allowlist match, + and the resulting directory is normalized and required to live inside + one of the plugin search dirs, so a crafted id can never name a path + outside them. """ if not isinstance(plugin_id, str) or not _SAFE_PLUGIN_ID_RE.match(plugin_id): return None @@ -124,8 +126,15 @@ def find_plugin_dir(plugin_id: str) -> Optional[Path]: if not search_dir.exists(): continue result = loader.find_plugin_directory(plugin_id, search_dir) - if result: - return Path(result) + if not result: + continue + # Normalize WITHOUT following symlinks (dev plugins are often + # symlinked into plugins/) and require lexical containment in the + # search dir, so no id can ever name a path outside it. + result_abs = os.path.abspath(str(result)) + root_abs = os.path.abspath(str(search_dir)) + if os.path.commonpath([result_abs, root_abs]) == root_abs: + return Path(result_abs) return None