mirror of
https://github.com/espressif/esp-idf.git
synced 2026-04-27 19:13:21 +00:00
feat(tools): Restrict loading extension components to trusted sources
This commit is contained in:
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user