-
Notifications
You must be signed in to change notification settings - Fork 17.5k
Validate deferred trigger classpath resolves to a BaseTrigger subclass #69792
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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. | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -1717,8 +1717,22 @@ def get_trigger_by_classpath(self, classpath: str) -> type[BaseTrigger]: | |
| """ | ||
| 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)): | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think this should be pre-import check, unfortunately - by the time we get here the class is already imported, and the whole idea is to avoid even importing it - because just importing it can have side-effects. I am not sure if that one is even reasonably doable - because in order to get class hierarchy you need to import it. Just AST parsing will not solve it. So I am not sure if that is solving such defense-in-depth is even doable.
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Correct - the issubclass check runs after import_string, so it doesn't stop import-time side effects of an arbitrary module. To be precise about what it does stop: the reported path is classpath="subprocess.check_output" + trigger_kwargs, where the code executes at trigger_class(**kwargs) (the call) - importing stdlib subprocess is side-effect-free, so the issubclass gate blocks the actual execution. It does not help when the classpath points at a module whose import has side effects (e.g. an attacker-plantable module on sys.path). A genuinely pre-import defense is doable, just not via issubclass (which needs the class, hence the import). It has to be a string check on the classpath before import_string - a module-namespace allow-list: That never imports anything outside the trusted namespace (core airflow.* + providers airflow.providers.*), so import-time side effects of untrusted/planted modules are never triggered. The issubclass check can stay after import as defense-in-depth. The trade-off is custom triggers in user modules (mycompany.triggers.X): those would need a configurable prefix allow-list (similar to [core] allowed_deserialization_classes) to keep working - a behaviour change plus a new config option, which is why I wanted to check the direction before implementing. |
||
| 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] | ||
Uh oh!
There was an error while loading. Please reload this page.