diff --git a/docs/en/api-guides/tools/idf-py.rst b/docs/en/api-guides/tools/idf-py.rst index 6f8ea8bec4..0c531d44b0 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/docs/zh_CN/api-guides/tools/idf-py.rst b/docs/zh_CN/api-guides/tools/idf-py.rst index a92b358554..ab0cd147c7 100644 --- a/docs/zh_CN/api-guides/tools/idf-py.rst +++ b/docs/zh_CN/api-guides/tools/idf-py.rst @@ -501,6 +501,16 @@ ESP-IDF 支持 `CMake presets`_ 以简化多个构建配置的管理。此功能 - **参与构建的组件**:在项目根目录,或注册在项目 ``CMakeLists.txt`` 中的组件根目录,放置名为 ``idf_ext.py`` 的文件,该文件会在项目配置完成后得到识别。运行 ``idf.py build`` 或 ``idf.py reconfigure``,新添加的命令即可生效。 - **Python 入口点**:对于任何已安装的 Python 包,在 ``idf_extension`` 组中定义入口点后,就可以提供扩展功能。只要安装了 Python 包就可以使用扩展功能,无需重新构建项目。 +出于安全考虑,组件扩展仅从可信来源加载: + +- ESP-IDF 内置组件(位于 ``IDF_PATH/components`` 下)。 +- 项目组件(项目自身的 ``components/`` 目录)。 +- 在项目顶层 ``CMakeLists.txt`` 中,由 ``EXTRA_COMPONENT_DIRS`` 所列目录中的用户自定义组件。 +- 乐鑫组件注册表 (``https://components.espressif.com/``) 中的乐鑫组件,仅 ``espressif/`` 命名空间受信任。 +- 下载到 ``IDF_TOOLS_PATH/root_managed_components/`` 目录下的 IDF 托管组件。仅 ``espressif/`` 命名空间受信任。 + +其他来源的扩展(例如通过 ``git``、本地 ``path`` 或 ``override_path`` 解析的组件)会被跳过,并给出警告。若要加载所有组件中的扩展,请设置 ``IDF_EXTENSION_ALLOW_UNTRUSTED=1``。 + .. important:: 扩展不能定义与 ``idf.py`` 命令同名的子命令或选项。系统会检查自定义的动作和选项名称是否存在冲突,不允许覆盖默认命令,如有冲突会打印警告。对于 Python 入口点,必须使用唯一标识符,否则会忽略重复的入口点名称并发出警告。 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