This is an automated email from the ASF dual-hosted git repository.
vatsrahul1001 pushed a commit to branch v3-3-test
in repository https://gitbox.apache.org/repos/asf/airflow.git
The following commit(s) were added to refs/heads/v3-3-test by this push:
new 4498cefce56 [v3-3-test] Restrict exception-node deserialization to
known classes without importing the stored name (#68511) (#71133)
4498cefce56 is described below
commit 4498cefce5609c66a92f1c06c020415dfd72b3cc
Author: github-actions[bot]
<41898282+github-actions[bot]@users.noreply.github.com>
AuthorDate: Wed Aug 5 12:46:42 2026 +0530
[v3-3-test] Restrict exception-node deserialization to known classes
without importing the stored name (#68511) (#71133)
* Resolve serialized exception nodes without importing the stored class name
When deserializing AIRFLOW_EXC_SER / BASE_EXC_SER nodes, BaseSerialization
resolved the exception class with import_string() on a name taken from the
serialized blob. Resolve it against in-memory classes instead, so a stored
DAG never imports a class named in the blob:
- AIRFLOW_EXC_SER: look the name up in a map of loaded AirflowException
subclasses, built once from the in-memory subclass tree; a name that is
not a registered AirflowException subclass is rejected.
- BASE_EXC_SER: resolve against the fixed {KeyError, AttributeError} set
that the encoder is the only producer of.
Unknown or disallowed names raise DeserializationError instead of being
imported. The trigger-node branch is handled separately.
Generated-by: Claude Opus 4.8 following the guidelines at
https://github.com/apache/airflow/blob/main/contributing-docs/05_pull_requests.rst#gen-ai-assisted-contributions
* Resolve serialized exception names from loaded modules instead of a
prebuilt map
A prebuilt map keyed on each class's __module__ cannot follow the
re-exports that
import_string used to follow. These exceptions moved to
airflow.sdk.exceptions in
3.2.0, so every blob written by 3.0/3.1 names airflow.exceptions.<Name> and
would
stop deserializing on upgrade.
Reading the name out of an already-loaded module keeps that working, needs
no cache
to go stale when a provider or plugin registers its own subclass late, and
still
refuses to import anything the stored blob names.
* Condense the docstrings around exception-name resolution
Review feedback: the explanation was longer than the behaviour it describes.
(cherry picked from commit 3a08a3d7792335ac9456cd44c92d8de3ae0d6ec1)
Co-authored-by: Jarek Potiuk <[email protected]>
Co-authored-by: Rahul Vats <[email protected]>
---
.../airflow/serialization/serialized_objects.py | 36 +++++-
.../unit/serialization/test_dag_serialization.py | 121 ++++++++++++++++++++-
2 files changed, 153 insertions(+), 4 deletions(-)
diff --git a/airflow-core/src/airflow/serialization/serialized_objects.py
b/airflow-core/src/airflow/serialization/serialized_objects.py
index 54bc3389c64..f42e51e28f7 100644
--- a/airflow-core/src/airflow/serialization/serialized_objects.py
+++ b/airflow-core/src/airflow/serialization/serialized_objects.py
@@ -41,7 +41,7 @@ import pydantic
from dateutil import relativedelta
from pendulum.tz.timezone import FixedTimezone, Timezone
-from airflow._shared.module_loading import import_string, qualname
+from airflow._shared.module_loading import qualname
from airflow._shared.timezones.timezone import from_timestamp, parse_timezone,
utcnow
from airflow.callbacks.callback_requests import DagCallbackRequest,
TaskCallbackRequest
from airflow.exceptions import AirflowException, DeserializationError,
SerializationError
@@ -242,6 +242,31 @@ def _decode_priority_weight_strategy(var: str) ->
PriorityWeightStrategy:
return priority_weight_strategy_class()
+# Builtin exceptions a BASE_EXC_SER node can rebuild into. A user defined
subclass
+# (e.g. a custom KeyError) serializes to a name absent here and won't
round-trip --
+# unchanged from before, since builtins never held it either.
+_DESERIALIZABLE_BUILTIN_EXCEPTIONS: dict[str, type[BaseException]] = {
+ "KeyError": KeyError,
+ "AttributeError": AttributeError,
+}
+
+
+def _resolve_airflow_exception(exc_cls_name: str) -> type[AirflowException]:
+ """
+ Resolve a stored ``AirflowException`` name without importing it.
+
+ Read via ``vars()`` rather than ``getattr`` -- a module's deprecation-shim
+ ``__getattr__`` can still import on access -- which also lets pre-3.2.0
blobs
+ naming the old ``airflow.exceptions`` path keep resolving.
+ """
+ module_name, _, attr_name = exc_cls_name.rpartition(".")
+ module = sys.modules.get(module_name)
+ exc_cls = vars(module).get(attr_name) if module is not None else None
+ if not (isinstance(exc_cls, type) and issubclass(exc_cls,
AirflowException)):
+ raise DeserializationError(f"Refusing to deserialize unknown exception
class {exc_cls_name!r}")
+ return exc_cls
+
+
def _encode_start_trigger_args(var: StartTriggerArgs) -> dict[str, Any]:
"""Encode a StartTriggerArgs."""
@@ -664,9 +689,14 @@ class BaseSerialization:
kwargs = deser["kwargs"]
del deser
if type_ == DAT.AIRFLOW_EXC_SER:
- exc_cls = import_string(exc_cls_name)
+ exc_cls: type[BaseException] =
_resolve_airflow_exception(exc_cls_name)
else:
- exc_cls = import_string(f"builtins.{exc_cls_name}")
+ builtin_exc_cls =
_DESERIALIZABLE_BUILTIN_EXCEPTIONS.get(exc_cls_name)
+ if builtin_exc_cls is None:
+ raise DeserializationError(
+ f"Refusing to deserialize unsupported builtin
exception {exc_cls_name!r}"
+ )
+ exc_cls = builtin_exc_cls
return exc_cls(*args, **kwargs)
elif type_ == DAT.SET:
return {cls.deserialize(v) for v in var}
diff --git a/airflow-core/tests/unit/serialization/test_dag_serialization.py
b/airflow-core/tests/unit/serialization/test_dag_serialization.py
index 07459a20117..db079840c6b 100644
--- a/airflow-core/tests/unit/serialization/test_dag_serialization.py
+++ b/airflow-core/tests/unit/serialization/test_dag_serialization.py
@@ -52,6 +52,7 @@ from airflow._shared.timezones import timezone
from airflow.dag_processing.dagbag import DagBag
from airflow.exceptions import (
AirflowException,
+ DeserializationError,
ParamValidationError,
SerializationError,
)
@@ -77,7 +78,7 @@ from airflow.serialization.definitions.operatorlink import
XComOperatorLink
from airflow.serialization.definitions.param import SerializedParam
from airflow.serialization.definitions.xcom_arg import SchedulerPlainXComArg
from airflow.serialization.encoders import ensure_serialized_asset
-from airflow.serialization.enums import Encoding
+from airflow.serialization.enums import DagAttributeTypes, Encoding
from airflow.serialization.json_schema import load_dag_schema_dict
from airflow.serialization.serialized_objects import (
BaseSerialization,
@@ -519,6 +520,10 @@ def timetable_plugin(monkeypatch: pytest.MonkeyPatch):
)
+class PluginContributedError(AirflowException):
+ """Defined outside airflow.exceptions on purpose: resolution must not be
limited to exceptions Airflow itself ships."""
+
+
class TestStringifiedDAGs:
"""Unit tests for stringified DAGs."""
@@ -2790,6 +2795,120 @@ class TestStringifiedDAGs:
dr = dag_maker.create_dagrun(partition_key="runtime-key")
assert dr.partition_key == "runtime-key"
+ @pytest.mark.parametrize(
+ ("module_name", "attr_name"),
+ [
+ pytest.param("subprocess", "check_output",
id="callable_in_loaded_module"),
+ pytest.param("os", "system", id="another_callable"),
+ pytest.param("builtins", "eval", id="builtin_callable"),
+ pytest.param("airflow.exceptions", "NoSuchThing",
id="missing_attr"),
+ ],
+ )
+ def
test_airflow_exc_deserialization_rejects_a_name_in_a_loaded_module(self,
module_name, attr_name):
+ """Having the module loaded is not enough -- the name must be an
AirflowException subclass.
+
+ The module is imported by the test first, so the rejection cannot be
an artefact of the
+ module simply being absent.
+ """
+ importlib.import_module(module_name)
+ encoded = BaseSerialization._encode(
+ BaseSerialization.serialize(
+ {"exc_cls_name": f"{module_name}.{attr_name}", "args": [],
"kwargs": {}}
+ ),
+ type_=DagAttributeTypes.AIRFLOW_EXC_SER,
+ )
+ with pytest.raises(DeserializationError, match="Refusing to
deserialize unknown exception class"):
+ BaseSerialization.deserialize(encoded)
+
+ @pytest.mark.parametrize(
+ "exc_cls_name",
+ [
+ pytest.param("not.a.loaded.module.Thing", id="unloaded_module"),
+ pytest.param("NoModulePart", id="no_module_part"),
+ pytest.param("", id="empty"),
+ ],
+ )
+ def test_airflow_exc_deserialization_rejects_an_unresolvable_name(self,
exc_cls_name):
+ encoded = BaseSerialization._encode(
+ BaseSerialization.serialize({"exc_cls_name": exc_cls_name, "args":
[], "kwargs": {}}),
+ type_=DagAttributeTypes.AIRFLOW_EXC_SER,
+ )
+ with pytest.raises(DeserializationError, match="Refusing to
deserialize unknown exception class"):
+ BaseSerialization.deserialize(encoded)
+
+ def
test_airflow_exc_deserialization_does_not_import_the_named_module(self):
+ """Resolution reads ``sys.modules``; it never imports what the blob
names."""
+ module_name = "airflow_exc_module_that_must_not_be_imported"
+ encoded = BaseSerialization._encode(
+ BaseSerialization.serialize({"exc_cls_name":
f"{module_name}.Boom", "args": [], "kwargs": {}}),
+ type_=DagAttributeTypes.AIRFLOW_EXC_SER,
+ )
+ with mock.patch.object(
+ importlib, "import_module",
side_effect=AssertionError("imported"), autospec=True
+ ):
+ with pytest.raises(DeserializationError):
+ BaseSerialization.deserialize(encoded)
+ assert module_name not in sys.modules
+
+ def test_airflow_exc_deserialization_roundtrips_airflow_exception(self):
+ result =
BaseSerialization.deserialize(BaseSerialization.serialize(AirflowException("boom")))
+ assert isinstance(result, AirflowException)
+ assert result.args == ("boom",)
+
+ def
test_airflow_exc_deserialization_resolves_a_subclass_outside_airflow(self):
+ """Resolves as long as its module is loaded, registration order
doesn't matter."""
+ result =
BaseSerialization.deserialize(BaseSerialization.serialize(PluginContributedError("boom")))
+ assert isinstance(result, PluginContributedError)
+ assert result.args == ("boom",)
+
+ @pytest.mark.parametrize(
+ "exc_cls_name",
+ [
+ "airflow.exceptions.AirflowException",
+ "airflow.exceptions.AirflowNotFoundException",
+ "airflow.exceptions.ParamValidationError",
+ "airflow.sdk.exceptions.AirflowException",
+ ],
+ )
+ def
test_airflow_exc_deserialization_accepts_the_pre_3_2_module_spelling(self,
exc_cls_name):
+ """A blob written before these exceptions moved to
``airflow.sdk.exceptions`` still reads.
+
+ 3.0/3.1 stored ``airflow.exceptions.<Name>``; 3.2.0 moved the classes
and left a re-export
+ behind. Both spellings have to resolve, or upgrading strands every
stored blob that carries
+ an exception node.
+ """
+ encoded = BaseSerialization._encode(
+ BaseSerialization.serialize({"exc_cls_name": exc_cls_name, "args":
["boom"], "kwargs": {}}),
+ type_=DagAttributeTypes.AIRFLOW_EXC_SER,
+ )
+ result = BaseSerialization.deserialize(encoded)
+ assert isinstance(result, AirflowException)
+
+ def test_base_exc_deserialization_rejects_non_allowlisted_builtin(self):
+ """A BASE_EXC_SER name outside the {KeyError, AttributeError} the
encoder emits is rejected."""
+ # ``eval`` is the weaponisable case; ``ValueError`` is a harmless
builtin the encoder
+ # never emits as BASE_EXC_SER -- both must be rejected.
+ for name in ("eval", "ValueError"):
+ encoded = BaseSerialization._encode(
+ BaseSerialization.serialize({"exc_cls_name": name, "args":
["1"], "kwargs": {}}),
+ type_=DagAttributeTypes.BASE_EXC_SER,
+ )
+ with pytest.raises(
+ DeserializationError, match="Refusing to deserialize
unsupported builtin exception"
+ ):
+ BaseSerialization.deserialize(encoded)
+
+ @pytest.mark.parametrize("exc_type", [KeyError, AttributeError])
+ def test_base_exc_serialize_deserialize_round_trip(self, exc_type):
+ """Pins the allow-list to the encode branch, by going through
``serialize`` rather than
+ hand-building the node: if that branch ever accepts another builtin,
this fails."""
+ result =
BaseSerialization.deserialize(BaseSerialization.serialize(exc_type("boom")))
+
+ assert isinstance(result, exc_type)
+ # The encode branch stores ``[var.args]``, so the args arrive nested.
Pre-existing shape,
+ # asserted as it is rather than as it ought to be.
+ assert result.args == (("boom",),)
+
def test_kubernetes_optional():
"""Test that serialization module loads without kubernetes, but
deserialization of PODs requires it"""