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 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.
|
- **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::
|
.. 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.
|
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
|
#!/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
|
# SPDX-License-Identifier: Apache-2.0
|
||||||
#
|
#
|
||||||
@@ -45,6 +45,7 @@ try:
|
|||||||
from idf_py_actions.tools import PropertyDict
|
from idf_py_actions.tools import PropertyDict
|
||||||
from idf_py_actions.tools import debug_print_idf_version
|
from idf_py_actions.tools import debug_print_idf_version
|
||||||
from idf_py_actions.tools import get_target
|
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 merge_action_lists
|
||||||
from idf_py_actions.tools import print_warning
|
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 keyword + path.split(keyword, 1)[1]
|
||||||
return path
|
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
|
# 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
|
# fully featured click parser to be sure that extensions are loaded from the right place
|
||||||
@click.command(
|
@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}"')
|
print_warning(f'WARNING: Cannot load idf.py extension "{name}"')
|
||||||
|
|
||||||
component_idf_ext_dirs = []
|
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()
|
build_dir_path = resolve_build_dir()
|
||||||
project_description_json_file = os.path.join(build_dir_path, 'project_description.json')
|
project_description_json_file = os.path.join(build_dir_path, 'project_description.json')
|
||||||
if os.path.exists(project_description_json_file):
|
if os.path.exists(project_description_json_file):
|
||||||
try:
|
try:
|
||||||
with open(project_description_json_file, encoding='utf-8') as f:
|
with open(project_description_json_file, encoding='utf-8') as f:
|
||||||
project_desc = json.load(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:
|
except (OSError, json.JSONDecodeError) as e:
|
||||||
print_warning(f'Warning: Failed to read component info from project_description.json: {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)
|
# Load extensions from directories that participate in the build (components and project)
|
||||||
for ext_dir in component_idf_ext_dirs + [project_dir]:
|
for ext_dir in component_idf_ext_dirs + [project_dir]:
|
||||||
extension_func = load_cli_extension_from_dir(ext_dir)
|
extension_func = load_cli_extension_from_dir(ext_dir)
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ def executable_exists(args: list) -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def _idf_version_from_cmake() -> str | None:
|
def idf_version_from_cmake() -> str | None:
|
||||||
"""Acquires version of ESP-IDF from version.cmake"""
|
"""Acquires version of ESP-IDF from version.cmake"""
|
||||||
version_path = os.path.join(os.environ['IDF_PATH'], 'tools/cmake/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+)')
|
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:
|
except Exception:
|
||||||
# if failed, then try to parse cmake.version file
|
# if failed, then try to parse cmake.version file
|
||||||
sys.stderr.write('WARNING: Git version unavailable, reading from source\n')
|
sys.stderr.write('WARNING: Git version unavailable, reading from source\n')
|
||||||
version = _idf_version_from_cmake()
|
version = idf_version_from_cmake()
|
||||||
|
|
||||||
return version
|
return version
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user