fix(dev-server): derive plugin dir from trusted directory listings

CodeQL's barrier-guard recognition doesn't see a startswith check
inside an any() comprehension, so the normalize-and-prefix approach
still flagged. Break the taint outright instead: after lookup, re-derive
the directory by enumerating the search dirs (iterdir) and matching by
path equality — the Path used for all downstream file access is built
solely from trusted listings, never from request input.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FqzC1nzTWL4kaqgMaQZFam
This commit is contained in:
Chuck
2026-07-11 12:45:17 -04:00
co-authored by Claude Fable 5
parent 149f1daa1e
commit a11c59698a
+22 -10
View File
@@ -256,24 +256,36 @@ def _render_once(plugin_id, plugin_dir, manifest, config, mock_data, width, heig
} }
def _trusted_plugin_dir(plugin_dir: Path) -> Optional[Path]:
"""Re-derive a plugin directory from the search dirs' own listings.
Path-injection barrier: the returned Path is constructed purely from
trusted directory enumeration (``iterdir``) — request-derived strings
never enter its construction — so a crafted plugin id can never make
downstream file access leave the plugin search dirs. Comparison is by
path equality, deliberately without symlink resolution (dev plugins
are commonly symlinked into plugins/).
"""
wanted = Path(os.path.normpath(str(plugin_dir)))
for search_dir in get_search_dirs():
if not search_dir.is_dir():
continue
for entry in search_dir.iterdir():
if entry.is_dir() and entry == wanted:
return entry
return None
def _parse_render_request(data): def _parse_render_request(data):
"""Shared /api/render* request prep. Returns (plugin_dir, manifest, config, """Shared /api/render* request prep. Returns (plugin_dir, manifest, config,
mock_data, skip_update) or raises ValueError with a client message.""" mock_data, skip_update) or raises ValueError with a client message."""
plugin_id = data['plugin_id'] plugin_id = data['plugin_id']
plugin_dir = find_plugin_dir(plugin_id) plugin_dir = find_plugin_dir(plugin_id)
if plugin_dir:
plugin_dir = _trusted_plugin_dir(plugin_dir)
if not plugin_dir: if not plugin_dir:
raise LookupError(f'Plugin not found: {plugin_id}') raise LookupError(f'Plugin not found: {plugin_id}')
# Path-injection barrier: normalize (no symlink resolution — dev plugins
# are commonly symlinked into plugins/) and require the directory to sit
# inside one of the plugin search dirs before any file access.
normalized = os.path.normpath(str(plugin_dir))
allowed_roots = [os.path.normpath(str(d)) for d in get_search_dirs()]
if not any(normalized == root or normalized.startswith(root + os.sep)
for root in allowed_roots):
raise LookupError(f'Plugin not found: {plugin_id}')
plugin_dir = Path(normalized)
manifest_path = plugin_dir / 'manifest.json' manifest_path = plugin_dir / 'manifest.json'
with open(manifest_path, 'r') as f: with open(manifest_path, 'r') as f:
manifest = json.load(f) manifest = json.load(f)