Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,40 @@ The semantic versioning only considers the public API as described in
paths are considered internals and can change in minor and patch releases.


v4.52.0 (unreleased)
--------------------

Added
^^^^^
- New ``import_path_denylist`` and ``import_path_allowlist`` settings in
``set_parsing_settings`` that limit which import paths a value is allowed to
name, so that configs from an untrusted source can't reach arbitrary code. A
set of standard library paths that give code execution, e.g. ``os``,
``subprocess`` and ``pickle``, is denied by default, see
:ref:`untrusted-configs` (`#959
<https://github.com/mauvilsa/jsonargparse/pull/959>`__).

Fixed
^^^^^
- ``Callable`` types that have a class as return type, e.g. ``Callable[...,
Model]``, and instance factory protocols, accepted any class whose instances
are callable and any function, instead of only subclasses of the return type
and functions that return it (`#959
<https://github.com/mauvilsa/jsonargparse/pull/959>`__).
- Instance factory protocols with a ``__call__`` that takes no parameters
instantiated the class instead of giving a factory (`#959
<https://github.com/mauvilsa/jsonargparse/pull/959>`__).

Deprecated
^^^^^^^^^^
- Values that name a denied import path, e.g. a ``class_path`` of
``subprocess.Popen``, currently only emit a deprecation warning and the import
proceeds. From v5.0.0 they will fail. Give a value to ``import_path_denylist``
or ``import_path_allowlist`` in ``set_parsing_settings``, an empty list
included, to get the future behavior now and silence the warning (`#959
<https://github.com/mauvilsa/jsonargparse/pull/959>`__).


v4.51.0 (2026-08-20)
--------------------

Expand Down
85 changes: 82 additions & 3 deletions DOCUMENTATION.rst
Original file line number Diff line number Diff line change
Expand Up @@ -2352,9 +2352,9 @@ be accepted. In this case the config would be like:

.. note::

It is also possible to provide to ``class_path`` a function that has as return
type a class. The accepted ``init_args`` would be the parameters of that
function.
It is also possible to provide to ``class_path`` a function that has as
return type a class. The accepted ``init_args`` would be the parameters of
that function.

.. note::

Expand All @@ -2363,6 +2363,85 @@ be accepted. In this case the config would be like:
reason they are not included in the known subclasses shown in the help.


.. _untrusted-configs:

Untrusted configs
-----------------

Resolving a ``class_path`` imports the named module and instantiates the named
class with the given ``init_args``, so a config decides what code runs. When the
configs come from a trusted source, e.g. the same repository as the code, this
is not a concern. When they don't, e.g. a config uploaded by a user of a
service, an import path denylist limits what a config can reach.

Import paths that come from a value, i.e. a ``class_path``, a ``Callable``, a
``type[...]`` or a ``types.ModuleType`` given in a config file, the command line
or an environment variable, are checked against a denylist before the import
happens. Paths that come from code, e.g. type annotations and defaults, are
never checked. jsonargparse denies a set of paths by default, mostly standard
library modules that give arbitrary code execution, e.g. ``os``, ``subprocess``,
``pickle`` and ``importlib``. Two settings adjust the list:

.. testsetup:: import_paths

saved_import_path_settings = dict(_common.parsing_settings)

.. testcode:: import_paths

from jsonargparse import set_parsing_settings

set_parsing_settings(
import_path_denylist=["mypackage._internal"],
import_path_allowlist=["functools.partial"],
)

An entry denies or allows a dot import path and everything under it, so ``os``
also denies ``os.system``. The most specific entry decides, which is why
``functools.partial`` above is allowed even though ``functools`` is denied by
default. An entry given in both lists is allowed, so naming a default entry in
``import_path_allowlist`` is how to stop denying it.

An object is denied by where it is defined, not only by the path used to reach
it. Modules commonly import others, e.g. ``import os``, so without this
``some.module.os.system`` would give the same object as the denied
``os.system``. This second check can only happen once the object is resolved, so
it prevents the object from being used, unlike the check on the given path,
which prevents the import from happening at all.

Entries given are added to the ones denied by default, they don't replace them.
For configs that are entirely untrusted, prefer denying everything and allowing
only what the application expects. The ``*`` entry is only accepted in
``import_path_denylist``:

.. testcode:: import_paths

set_parsing_settings(
import_path_denylist=["*"],
import_path_allowlist=["mypackage.tools"],
)

.. testcleanup:: import_paths

_common.parsing_settings.clear()
_common.parsing_settings.update(saved_import_path_settings)

.. note::

A denylist is a mitigation, not a sandbox. A large enough set of installed
dependencies is likely to contain something that reaches a denied capability
without naming a denied path, e.g. a class that runs a command given to it.
Only ``*`` plus a narrow allowlist gives a bound on what a config can
import.

.. note::

Until v5.0.0 a denied import path only gives a deprecation warning and the
import proceeds, so that existing configs don't break. Giving a value to
``import_path_denylist`` or ``import_path_allowlist``, an empty list
included, makes denied import paths fail instead. From v5.0.0 they always
fail.


.. _sub-config-files:

Sub-config files
Expand Down
16 changes: 13 additions & 3 deletions jsonargparse/_actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,15 @@
from contextvars import ContextVar
from typing import Any

from ._common import Action, NonParsingAction, get_parsing_setting, is_subclass, is_subclasses_disabled, parser_context
from ._common import (
Action,
ImportDenied,
NonParsingAction,
get_parsing_setting,
is_subclass,
is_subclasses_disabled,
parser_context,
)
from ._loaders_dumpers import get_loader_exceptions, load_value
from ._namespace import Namespace
from ._optionals import _get_config_read_mode, ruamel_support
Expand Down Expand Up @@ -273,7 +281,7 @@
namespace[self.dest] = loaded_value
return None

def resolve_subclass_spec(self, value):

Check failure on line 284 in jsonargparse/_actions.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this function to reduce its Cognitive Complexity from 16 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=mauvilsa_jsonargparse&issues=AaAkcz3m0VcEImD985Di&open=AaAkcz3m0VcEImD985Di&pullRequest=959
"""Resolves a subclass spec given for a subclasses disabled type, e.g. a dataclass.

These types are added as a group, such that their init args are individual arguments. Still a
Expand All @@ -288,6 +296,8 @@
def resolve_class(class_path):
try:
return import_object(resolve_class_path_by_name(self.basetype, class_path))
except ImportDenied:
raise
except Exception:
return None

Expand Down Expand Up @@ -321,7 +331,7 @@
with load_config_path_context(cfg_path), change_to_path_dir(cfg_path):
cfg = parser._apply_actions(cfg, parent_key=self.dest)
return cfg
except SubclassesDisabledError as ex:
except (SubclassesDisabledError, ImportDenied) as ex:
raise TypeError(f'Parser key "{self.dest}":\n{indent_text(str(ex))}') from ex
except (TypeError,) + get_loader_exceptions() as ex:
str_ex = indent_text(f"- {ex}")
Expand Down Expand Up @@ -560,7 +570,7 @@
ValueError: If the parser parameter is invalid.
"""
self._parser = parser
if not isinstance(self._parser, import_object("jsonargparse.ArgumentParser")):
if not isinstance(self._parser, import_object("jsonargparse.ArgumentParser", check_path=False)):
raise ValueError("Expected parser keyword argument to be an ArgumentParser.")

@staticmethod
Expand Down
Loading