feat(tools): Restrict loading extension components to trusted sources

This commit is contained in:
Marek Fiala
2026-02-26 16:54:27 +01:00
committed by BOT
parent 54ecf4c09f
commit 4d1612cd3c
3 changed files with 112 additions and 9 deletions
+10
View File
@@ -501,6 +501,16 @@ Extending ``idf.py``
- **From components participating in the build**: Place a file named ``idf_ext.py`` in the project root or in a component's root directory that is registered in the project's ``CMakeLists.txt``. Component extensions are discovered after the project is configured - run ``idf.py build`` or ``idf.py reconfigure`` to make newly added commands available.
- **From Python entry points**: Any installed Python package may contribute extensions by defining an entry point in the ``idf_extension`` group. Package installation is sufficient, no project build is required.
For security reasons, component extensions are loaded from trusted sources only:
- ESP-IDF built-in components (under ``IDF_PATH/components``)
- Project components (the project's own ``components/`` directory)
- User-defined components from directories listed in ``EXTRA_COMPONENT_DIRS`` in the project's top-level ``CMakeLists.txt``
- Espressif components from the ESP Component Registry (``https://components.espressif.com/``). Only the ``espressif/`` namespace is trusted, not all registry components
- IDF-managed components downloaded to the ``IDF_TOOLS_PATH/root_managed_components/`` directory. Only the ``espressif/`` namespace is trusted
Extensions from other sources (e.g., components resolved via ``git``, local ``path``, or ``override_path``) are skipped with a warning. To load extensions from all components, set ``IDF_EXTENSION_ALLOW_UNTRUSTED=1``.
.. important::
Extensions must not define subcommands or options that have the same names as the core ``idf.py`` commands. Custom actions and options are checked for name collisions, overriding defaults is not possible and a warning is printed. For Python entry points, use unique identifiers as duplicate entry point names will be ignored with a warning.
+100 -7
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env python
#
# SPDX-FileCopyrightText: 2019-2025 Espressif Systems (Shanghai) CO LTD
# SPDX-FileCopyrightText: 2019-2026 Espressif Systems (Shanghai) CO LTD
#
# SPDX-License-Identifier: Apache-2.0
#
@@ -45,6 +45,7 @@ try:
from idf_py_actions.tools import PropertyDict
from idf_py_actions.tools import debug_print_idf_version
from idf_py_actions.tools import get_target
from idf_py_actions.tools import idf_version_from_cmake
from idf_py_actions.tools import merge_action_lists
from idf_py_actions.tools import print_warning
@@ -830,6 +831,75 @@ def init_cli(verbose_output: list | None = None) -> Any:
return keyword + path.split(keyword, 1)[1]
return path
# Mutable dict used as a cache keyed by lock path
# Both keys are populated on first call for each path and retained for subsequent calls.
_trusted_names_cache: dict[str, set[str]] = {}
def _get_trusted_names_from_lock(lock_path: str) -> set[str]:
"""Return set of lock dependency names (e.g. espressif/mdns)
that are from the ESP Component Registry under the espressif/ namespace.
Result is cached by lock_path after the first call.
"""
if lock_path in _trusted_names_cache:
return _trusted_names_cache[lock_path]
result: set[str] = set()
_trusted_names_cache[lock_path] = result
if not os.path.isfile(lock_path) or os.getenv('IDF_COMPONENT_MANAGER') == '0':
return result
try:
from idf_component_tools.constants import IDF_COMPONENT_REGISTRY_URL
from idf_component_tools.errors import LockError
from idf_component_tools.lock.manager import LockManager
from idf_component_tools.sources import WebServiceSource
solution = LockManager(lock_path).load()
registry_url_norm = IDF_COMPONENT_REGISTRY_URL.rstrip('/')
for comp in solution.dependencies:
if not isinstance(comp.source, WebServiceSource):
continue
if comp.source.registry_url.rstrip('/') == registry_url_norm and (comp.name or '').startswith(
'espressif/'
):
result.add(comp.name)
except (ImportError, OSError, LockError) as e:
print_warning(
'WARNING: Could not verify source of external components. '
f'No extensions (idf_ext.py) from managed components will be loaded. ({e})'
)
return result
def _resolve_idf_managed_lock_path() -> str | None:
"""Return path to dependencies.lock for IDF-managed components, or None if unavailable."""
idf_tools_path = os.environ.get('IDF_TOOLS_PATH') or os.path.expanduser(os.path.join('~', '.espressif'))
ver = idf_version_from_cmake() # returns e.g. 'v6.1.0', or None on failure
if not ver:
return None
ver_str = ver.lstrip('v') # '6.1.0'
return os.path.join(idf_tools_path, 'root_managed_components', f'idf{ver_str}', 'dependencies.lock')
def _is_component_trusted(
comp_name: str,
source: str | None,
) -> bool:
"""True iff this component is from a trusted source (IDF, project, or Espressif component from ESP-registry)."""
if source in ('idf_components', 'project_components', 'project_extra_components'):
return True
if source == 'project_managed_components':
# Lock key: internal name uses __ (e.g. espressif__mdns), lock uses / (e.g. espressif/mdns)
lock_key = comp_name.replace('__', '/', 1) if '__' in comp_name else comp_name
return lock_key in _get_trusted_names_from_lock(os.path.join(project_dir, 'dependencies.lock'))
if source == 'idf_managed_components':
lock_path = _resolve_idf_managed_lock_path()
if lock_path is None:
return False
lock_key = comp_name.replace('__', '/', 1) if '__' in comp_name else comp_name
return lock_key in _get_trusted_names_from_lock(lock_path)
return False
# That's a tiny parser that parse project-dir even before constructing
# fully featured click parser to be sure that extensions are loaded from the right place
@click.command(
@@ -884,20 +954,43 @@ def init_cli(verbose_output: list | None = None) -> Any:
print_warning(f'WARNING: Cannot load idf.py extension "{name}"')
component_idf_ext_dirs = []
# Get component directories with idf extensions that participate in the build
# Get trusted component directories with idf extensions that participate in the build
project_desc = None
build_dir_path = resolve_build_dir()
project_description_json_file = os.path.join(build_dir_path, 'project_description.json')
if os.path.exists(project_description_json_file):
try:
with open(project_description_json_file, encoding='utf-8') as f:
project_desc = json.load(f)
all_component_info = project_desc.get('build_component_info', {})
for _, comp_info in all_component_info.items():
comp_dir = comp_info.get('dir')
if comp_dir and os.path.isdir(comp_dir) and os.path.exists(os.path.join(comp_dir, 'idf_ext.py')):
component_idf_ext_dirs.append(comp_dir)
except (OSError, json.JSONDecodeError) as e:
print_warning(f'Warning: Failed to read component info from project_description.json: {e}')
if project_desc is not None:
build_component_info = project_desc.get('build_component_info', {})
all_component_info = project_desc.get('all_component_info', {})
# The trust check runs during init_cli, before the main Click CLI is parsed,
# so only the env var is supported (a CLI flag would require early argv parsing).
skip_extension_trust_check = os.environ.get('IDF_EXTENSION_ALLOW_UNTRUSTED', '').lower() in ('1', 'true', 'yes')
for comp_name, comp_info in build_component_info.items():
comp_dir = comp_info.get('dir')
if not comp_dir or not os.path.isdir(comp_dir) or not os.path.exists(os.path.join(comp_dir, 'idf_ext.py')):
continue
if skip_extension_trust_check:
component_idf_ext_dirs.append(comp_dir)
else:
source = all_component_info.get(comp_name, {}).get('source')
if _is_component_trusted(comp_name, source):
component_idf_ext_dirs.append(comp_dir)
else:
print_warning(
f'WARNING: Not loading component extension from untrusted source '
f'"{_extract_relevant_path(comp_dir)}". '
'Only extensions from trusted sources are loaded. Run '
'"idf.py docs -sp api-guides/tools/idf-py.html#extending-idf-py" '
'for the list of trusted sources. Set IDF_EXTENSION_ALLOW_UNTRUSTED=1 to load all.'
)
# Load extensions from directories that participate in the build (components and project)
for ext_dir in component_idf_ext_dirs + [project_dir]:
extension_func = load_cli_extension_from_dir(ext_dir)
+2 -2
View File
@@ -76,7 +76,7 @@ def executable_exists(args: list) -> bool:
return False
def _idf_version_from_cmake() -> str | None:
def idf_version_from_cmake() -> str | None:
"""Acquires version of ESP-IDF from version.cmake"""
version_path = os.path.join(os.environ['IDF_PATH'], 'tools/cmake/version.cmake')
regex = re.compile(r'^\s*set\s*\(\s*IDF_VERSION_([A-Z]{5})\s+(\d+)')
@@ -124,7 +124,7 @@ def idf_version() -> str | None:
except Exception:
# if failed, then try to parse cmake.version file
sys.stderr.write('WARNING: Git version unavailable, reading from source\n')
version = _idf_version_from_cmake()
version = idf_version_from_cmake()
return version