This is an automated email from the ASF dual-hosted git repository.

potiuk pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git


The following commit(s) were added to refs/heads/main by this push:
     new 166a1ba1eec Validate deferred trigger classpath resolves to a 
BaseTrigger subclass (#69792)
166a1ba1eec is described below

commit 166a1ba1eecf969ed33ad17dd1232b20d7ce5aea
Author: Nguyen Van Hiep <[email protected]>
AuthorDate: Sun Aug 30 02:34:20 2026 +0700

    Validate deferred trigger classpath resolves to a BaseTrigger subclass 
(#69792)
    
    The triggerer resolves a trigger from the ``classpath`` carried in the
    deferred-task Execution API payload via ``import_string(classpath)`` and 
then
    instantiates it with ``trigger_class(**kwargs)``. 
``get_trigger_by_classpath``
    did not check that the imported object is actually a ``BaseTrigger`` 
subclass,
    so any importable callable (e.g. ``subprocess.check_output``) could be 
loaded
    and invoked in the long-running triggerer process.
    
    Harden this by rejecting, before caching and before instantiation, any
    ``classpath`` that does not resolve to a ``type`` that is a ``BaseTrigger``
    subclass. Legitimate triggers are unaffected (they are always 
``BaseTrigger``
    subclasses); an invalid classpath now fails the trigger cleanly instead of
    being instantiated. Adds a unit test covering both the accepted and rejected
    cases.
---
 airflow-core/newsfragments/69792.bugfix.rst          |  1 +
 .../src/airflow/jobs/triggerer_job_runner.py         | 16 +++++++++++++++-
 airflow-core/tests/unit/jobs/test_triggerer_job.py   | 20 ++++++++++++++++++++
 3 files changed, 36 insertions(+), 1 deletion(-)

diff --git a/airflow-core/newsfragments/69792.bugfix.rst 
b/airflow-core/newsfragments/69792.bugfix.rst
new file mode 100644
index 00000000000..5b9dfc15fc9
--- /dev/null
+++ b/airflow-core/newsfragments/69792.bugfix.rst
@@ -0,0 +1 @@
+Reject deferred-task trigger classpaths that do not resolve to a 
``BaseTrigger`` subclass before the class is instantiated in the triggerer, so 
a deferred task cannot cause an arbitrary importable callable to be invoked in 
the triggerer process.
diff --git a/airflow-core/src/airflow/jobs/triggerer_job_runner.py 
b/airflow-core/src/airflow/jobs/triggerer_job_runner.py
index 6af0d1d5dfc..982702256f0 100644
--- a/airflow-core/src/airflow/jobs/triggerer_job_runner.py
+++ b/airflow-core/src/airflow/jobs/triggerer_job_runner.py
@@ -1733,8 +1733,22 @@ class TriggerRunner:
         """
         Get a trigger class by its classpath ("path.to.module.classname").
 
+        The resolved object must be a 
:class:`~airflow.triggers.base.BaseTrigger`
+        subclass. This is validated before the class is cached and, crucially,
+        before it is ever instantiated in ``create_triggers`` -- ``classpath``
+        originates from the (attacker-influenceable) deferred-task payload, so
+        without this check an arbitrary importable callable could be invoked in
+        the triggerer process.
+
         Uses a cache dictionary to speed up lookups after the first time.
         """
         if classpath not in self.trigger_cache:
-            self.trigger_cache[classpath] = import_string(classpath)
+            trigger_class = import_string(classpath)
+            if not (isinstance(trigger_class, type) and 
issubclass(trigger_class, BaseTrigger)):
+                raise TypeError(
+                    f"The trigger classpath {classpath!r} does not resolve to 
a "
+                    f"{BaseTrigger.__module__}.{BaseTrigger.__qualname__} 
subclass; "
+                    f"refusing to load it."
+                )
+            self.trigger_cache[classpath] = trigger_class
         return self.trigger_cache[classpath]
diff --git a/airflow-core/tests/unit/jobs/test_triggerer_job.py 
b/airflow-core/tests/unit/jobs/test_triggerer_job.py
index 5c4c247d2a0..6f5de7eef8e 100644
--- a/airflow-core/tests/unit/jobs/test_triggerer_job.py
+++ b/airflow-core/tests/unit/jobs/test_triggerer_job.py
@@ -1196,6 +1196,26 @@ class TestTriggerRunner:
         trigger_runner = TriggerRunner()
         assert trigger_runner._shared_streams._cohort_grace_period == 3.0
 
+    def test_get_trigger_by_classpath_requires_basetrigger_subclass(self) -> 
None:
+        """
+        ``classpath`` comes from the (attacker-influenceable) deferred-task 
payload, so
+        ``get_trigger_by_classpath`` must refuse anything that is not a 
``BaseTrigger``
+        subclass before it is cached and instantiated -- otherwise an 
arbitrary importable
+        callable (e.g. ``subprocess.check_output``) could be invoked in the 
triggerer.
+        """
+        trigger_runner = TriggerRunner()
+
+        # A real BaseTrigger subclass resolves and is cached.
+        assert (
+            
trigger_runner.get_trigger_by_classpath("airflow.triggers.testing.SuccessTrigger")
+            is SuccessTrigger
+        )
+
+        # An arbitrary importable callable is rejected and never cached.
+        with pytest.raises(TypeError, match="does not resolve to a"):
+            trigger_runner.get_trigger_by_classpath("subprocess.check_output")
+        assert "subprocess.check_output" not in trigger_runner.trigger_cache
+
     @pytest.mark.asyncio
     async def 
test_block_watchdog_does_not_log_when_threshold_is_not_exceeded(self) -> None:
         with conf_vars({("triggerer", 
"blocked_main_thread_warning_threshold"): "0.5"}):

Reply via email to