From 4d1612cd3cdd27f2fea1d7f13a1ea3800af59325 Mon Sep 17 00:00:00 2001 From: Marek Fiala Date: Thu, 26 Feb 2026 16:54:27 +0100 Subject: [PATCH] feat(tools): Restrict loading extension components to trusted sources --- docs/en/api-guides/tools/idf-py.rst | 10 +++ tools/idf.py | 107 ++++++++++++++++++++++++++-- tools/idf_py_actions/tools.py | 4 +- 3 files changed, 112 insertions(+), 9 deletions(-) diff --git a/docs/en/api-guides/tools/idf-py.rst b/docs/en/api-guides/tools/idf-py.rst index 6f8ea8bec4..236eada7f5 100644 --- a/docs/en/api-guides/tools/idf-py.rst +++ b/docs/en/api-guides/tools/idf-py.rst @@ -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. diff --git a/tools/idf.py b/tools/idf.py index 2e2e08120c..028bac48c9 100755 --- a/tools/idf.py +++ b/tools/idf.py @@ -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) diff --git a/tools/idf_py_actions/tools.py b/tools/idf_py_actions/tools.py index 43318b7639..2ab73dc937 100644 --- a/tools/idf_py_actions/tools.py +++ b/tools/idf_py_actions/tools.py @@ -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