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 d675437ccdf Add an allowlist config option for Kafka connection string
callbacks (#72208)
d675437ccdf is described below
commit d675437ccdf87a3e36d8c8559bdcff6e62a5f618
Author: Christos Bisias <[email protected]>
AuthorDate: Tue Sep 8 14:10:06 2026 +0300
Add an allowlist config option for Kafka connection string callbacks
(#72208)
---
providers/apache/kafka/docs/configurations-ref.rst | 7 ++-
providers/apache/kafka/docs/connections/kafka.rst | 12 +++++
providers/apache/kafka/provider.yaml | 22 +++++++++
.../providers/apache/kafka/get_provider_info.py | 14 +++++-
.../airflow/providers/apache/kafka/hooks/base.py | 55 ++++++++++++++++++++--
.../tests/unit/apache/kafka/hooks/test_base.py | 55 ++++++++++++++++++++++
.../tests/unit/apache/kafka/hooks/test_consume.py | 3 ++
.../tests/unit/apache/kafka/hooks/test_produce.py | 3 ++
8 files changed, 164 insertions(+), 7 deletions(-)
diff --git a/providers/apache/kafka/docs/configurations-ref.rst
b/providers/apache/kafka/docs/configurations-ref.rst
index be5f3ad011d..2a5b108d497 100644
--- a/providers/apache/kafka/docs/configurations-ref.rst
+++ b/providers/apache/kafka/docs/configurations-ref.rst
@@ -76,8 +76,11 @@ To enable event publishing you need to
The connection's ``extra`` JSON accepts the full confluent-kafka client
configuration — including SASL/TLS options and callbacks (e.g. ``error_cb``,
-``oauth_cb``) given as dotted-path strings, which are resolved to callables
-before the producer is built.
+``oauth_cb``) given as dotted-path strings. A string-valued callback is only
+resolved when its full importable path is listed in the
+:ref:`config:apache_kafka__callback_allowlist` option; the example below
requires
+``callback_allowlist = my_company.auth.oauth_cb``. This is enforced for
security
+reasons, to prevent malicious callbacks from being executed.
.. code-block:: json
diff --git a/providers/apache/kafka/docs/connections/kafka.rst
b/providers/apache/kafka/docs/connections/kafka.rst
index 80954116615..486a5186d3f 100644
--- a/providers/apache/kafka/docs/connections/kafka.rst
+++ b/providers/apache/kafka/docs/connections/kafka.rst
@@ -40,6 +40,18 @@ used to specify a callback function by providing a path to
the function. e.g ``"
of parameters are described in the
`Confluent Kafka python library
<https://github.com/confluentinc/librdkafka/blob/master/CONFIGURATION.md>`_.
+.. warning::
+
+ Callback options supplied as dotted-path strings (``error_cb``,
``throttle_cb``, ``stats_cb``,
+ ``log_cb``, ``oauth_cb``, ``on_commit``) are only imported when listed in
the
+ :ref:`config:apache_kafka__callback_allowlist` configuration. Each
allowlist entry is the full
+ importable path of the callback itself — module plus attribute, e.g.
+ ``my_company.kafka.auth.oauth_cb`` — and must match the connection value
exactly; a bare module
+ such as ``my_company.kafka.auth`` does not lead to authorization of the
callables inside it.
+ This is enforced for security reasons, to prevent malicious callbacks from
being executed.
+ The allowlist is empty by default, which disables string-valued callbacks
entirely.
+ Managed authentication (Amazon MSK IAM, Google Managed Kafka) does not
rely on this and is unaffected.
+
If you are defining the Airflow connection from the Airflow UI, the ``extra``
field will be renamed to ``Config Dict``.
Most operators and hooks will check that at the minimum the
``bootstrap.servers`` key exists and has a value set to be valid.
diff --git a/providers/apache/kafka/provider.yaml
b/providers/apache/kafka/provider.yaml
index d530a1c5ff2..09f9eb64d92 100644
--- a/providers/apache/kafka/provider.yaml
+++ b/providers/apache/kafka/provider.yaml
@@ -138,6 +138,28 @@ plugins:
plugin-class:
airflow.providers.apache.kafka.plugins.event_producer.KafkaEventProducerPlugin
config:
+ apache_kafka:
+ description: |
+ Common settings for the Apache Kafka Provider.
+ options:
+ callback_allowlist:
+ description: |
+ Comma-separated list of callbacks that may be resolved from a Kafka
+ connection extra into confluent-kafka callback options (``error_cb``,
+ ``throttle_cb``, ``stats_cb``, ``log_cb``, ``oauth_cb``,
``on_commit``).
+ Each entry is the full importable path of the callback itself —
module
+ plus attribute, e.g. ``my_company.kafka.auth.oauth_cb`` — matched
exactly
+ against the dotted-path string on the connection extra (a bare
module such
+ as ``my_company.kafka.auth`` won't lead to authorization of the
callables
+ inside it). This is enforced for security reasons, to prevent
malicious
+ callbacks from being executed: only paths listed here are resolved,
and any
+ other string-valued callback is refused. Empty (default) disables
+ string-valued callbacks entirely. Managed authentication (Amazon MSK
IAM,
+ Google Managed Kafka) does not rely on this and is unaffected.
+ version_added: 1.16.0
+ type: string
+ example: "my_company.kafka.auth.oauth_cb"
+ default: ""
kafka_event_producer:
description: |
Settings for the Kafka event producer plugin that publishes Airflow
diff --git
a/providers/apache/kafka/src/airflow/providers/apache/kafka/get_provider_info.py
b/providers/apache/kafka/src/airflow/providers/apache/kafka/get_provider_info.py
index 31162c55425..80c1744717a 100644
---
a/providers/apache/kafka/src/airflow/providers/apache/kafka/get_provider_info.py
+++
b/providers/apache/kafka/src/airflow/providers/apache/kafka/get_provider_info.py
@@ -108,6 +108,18 @@ def get_provider_info():
}
],
"config": {
+ "apache_kafka": {
+ "description": "Common settings for the Apache Kafka
Provider.\n",
+ "options": {
+ "callback_allowlist": {
+ "description": "Comma-separated list of callbacks that
may be resolved from a Kafka\nconnection extra into confluent-kafka callback
options (``error_cb``,\n``throttle_cb``, ``stats_cb``, ``log_cb``,
``oauth_cb``, ``on_commit``).\nEach entry is the full importable path of the
callback itself — module\nplus attribute, e.g.
``my_company.kafka.auth.oauth_cb`` — matched exactly\nagainst the dotted-path
string on the connection extra (a bare module such\nas ``my_company [...]
+ "version_added": "1.16.0",
+ "type": "string",
+ "example": "my_company.kafka.auth.oauth_cb",
+ "default": "",
+ }
+ },
+ },
"kafka_event_producer": {
"description": "Settings for the Kafka event producer plugin
that publishes Airflow\nDagRun and TaskInstance state-change events to a Kafka
topic.\n",
"options": {
@@ -203,6 +215,6 @@ def get_provider_info():
"default": "60",
},
},
- }
+ },
},
}
diff --git
a/providers/apache/kafka/src/airflow/providers/apache/kafka/hooks/base.py
b/providers/apache/kafka/src/airflow/providers/apache/kafka/hooks/base.py
index 4fb4cc71707..5132fa8a1a9 100644
--- a/providers/apache/kafka/src/airflow/providers/apache/kafka/hooks/base.py
+++ b/providers/apache/kafka/src/airflow/providers/apache/kafka/hooks/base.py
@@ -23,12 +23,18 @@ from typing import Any
from confluent_kafka.admin import AdminClient
from airflow.providers.common.compat.module_loading import import_string
-from airflow.providers.common.compat.sdk import BaseHook
+from airflow.providers.common.compat.sdk import BaseHook, conf
# librdkafka config options whose values are callables. They can be provided
as dotted-path
# strings on the connection extra and are resolved to callables before the
client is built.
CALLBACK_CONFIG_KEYS = ("error_cb", "throttle_cb", "stats_cb", "log_cb",
"oauth_cb", "on_commit")
+
+KAFKA_COMMON_CONFIG_SECTION = "apache_kafka"
+# Configuration allowlist for callbacks that are allowed to be resolved from a
connection extra.
+CALLBACK_ALLOWLIST_CONFIG_OPTION = "callback_allowlist"
+
+
# Amazon MSK bootstrap servers follow a predictable naming scheme, e.g.
# b-1.demo.abcde1.c2.kafka.us-east-1.amazonaws.com:9098
(provisioned)
# boot-abcde1.c2.kafka-serverless.us-east-1.amazonaws.com:9098
(serverless)
@@ -90,12 +96,53 @@ class KafkaBaseHook(BaseHook):
def _get_client(self, config) -> Any:
return AdminClient(config)
+ @staticmethod
+ def _get_callback_allowlist() -> frozenset[str]:
+ conf_value = (
+ conf.get(KAFKA_COMMON_CONFIG_SECTION,
CALLBACK_ALLOWLIST_CONFIG_OPTION, fallback="") or ""
+ )
+ return frozenset(cb_path.strip() for cb_path in conf_value.split(",")
if cb_path.strip())
+
def _resolve_callbacks(self, config: dict[str, Any]) -> None:
- """Resolve callback options provided as dotted-path strings into
callables."""
+ """
+ Resolve callback options provided as dotted-path strings into
callables.
+
+ A callback is resolved only when its full importable path is listed in
+ the ``[apache_kafka] callback_allowlist`` configuration. This is
enforced
+ for security reasons, to prevent malicious callbacks from being
executed.
+ """
+ allowlist: frozenset[str] | None = None
for key in CALLBACK_CONFIG_KEYS:
value = config.get(key)
- if isinstance(value, str):
- config[key] = import_string(value)
+ if not isinstance(value, str):
+ continue
+ if allowlist is None:
+ # Get the allowlist from the config only once, if it hasn't
+ # already been initialized by a previous iteration.
+ allowlist = self._get_callback_allowlist()
+ if not allowlist:
+ # If the allowlist is empty, break the iteration immediately
by raising an error.
+ self.log.warning(
+ "Kafka connection %r requests callback %s=%r, but [%s] %s
is empty. Add the "
+ "full importable path of each callback you trust (e.g.
'my_pkg.auth.oauth_cb') "
+ "to allow string-valued callbacks.",
+ self.kafka_config_id,
+ key,
+ value,
+ KAFKA_COMMON_CONFIG_SECTION,
+ CALLBACK_ALLOWLIST_CONFIG_OPTION,
+ )
+ raise ValueError(
+ f"Refusing to resolve Kafka callback {key}={value!r}: the "
+ f"[{KAFKA_COMMON_CONFIG_SECTION}]
{CALLBACK_ALLOWLIST_CONFIG_OPTION} is empty."
+ )
+ if value not in allowlist:
+ raise ValueError(
+ f"Refusing to resolve Kafka callback {key}={value!r}: it
is not in the "
+ f"[{KAFKA_COMMON_CONFIG_SECTION}]
{CALLBACK_ALLOWLIST_CONFIG_OPTION} "
+ f"({', '.join(sorted(allowlist))})."
+ )
+ config[key] = import_string(value)
def _build_config(self) -> dict[str, Any]:
"""
diff --git a/providers/apache/kafka/tests/unit/apache/kafka/hooks/test_base.py
b/providers/apache/kafka/tests/unit/apache/kafka/hooks/test_base.py
index 4d472615344..bedc253942c 100644
--- a/providers/apache/kafka/tests/unit/apache/kafka/hooks/test_base.py
+++ b/providers/apache/kafka/tests/unit/apache/kafka/hooks/test_base.py
@@ -29,6 +29,10 @@ from airflow.providers.apache.kafka.hooks.base import (
)
from airflow.providers.common.compat.sdk import
AirflowOptionalProviderFeatureException
+from tests_common.test_utils.config import conf_vars
+
+CALLBACK_ALLOWLIST = {("apache_kafka", "callback_allowlist"):
"json.loads,json.dumps"}
+
try:
import importlib.util
@@ -66,6 +70,7 @@ class TestKafkaBaseHook:
with pytest.raises(ValueError, match="must be provided"):
hook.get_conn()
+ @conf_vars(CALLBACK_ALLOWLIST)
@mock.patch(f"{BASEHOOK_PATCH_PATH}.get_connection")
def test_callbacks_resolved_from_connection_dotted_path(self,
mock_get_connection):
stats_cb = MagicMock()
@@ -82,6 +87,41 @@ class TestKafkaBaseHook:
# Already-callable values on the connection are passed through
unchanged.
assert config["stats_cb"] is stats_cb
+ @conf_vars(CALLBACK_ALLOWLIST)
+ @mock.patch(f"{BASEHOOK_PATCH_PATH}.get_connection")
+ def test_callback_resolution_tolerates_whitespace_in_allowlist(self,
mock_get_connection):
+ mock_get_connection.return_value.extra_dejson = {
+ "bootstrap.servers": "localhost:9092",
+ "error_cb": "json.loads",
+ }
+ with conf_vars({("apache_kafka", "callback_allowlist"): " json.loads
,, json.dumps "}):
+ config = SomeKafkaHook().get_conn
+ assert config["error_cb"] is json.loads
+
+ @mock.patch.object(KafkaBaseHook, "log")
+ @mock.patch(f"{BASEHOOK_PATCH_PATH}.get_connection")
+ def test_string_callback_refused_when_allowlist_empty(self,
mock_get_connection, mock_log, hook):
+ mock_get_connection.return_value.extra_dejson = {
+ "bootstrap.servers": "localhost:9092",
+ "oauth_cb": "os.system",
+ }
+ with pytest.raises(ValueError, match="callback_allowlist is empty"):
+ _ = hook.get_conn
+ mock_log.warning.assert_called_once()
+
+ @conf_vars(CALLBACK_ALLOWLIST)
+ @mock.patch.object(KafkaBaseHook, "log")
+ @mock.patch(f"{BASEHOOK_PATCH_PATH}.get_connection")
+ def test_string_callback_refused_when_not_in_allowlist(self,
mock_get_connection, mock_log, hook):
+ mock_get_connection.return_value.extra_dejson = {
+ "bootstrap.servers": "localhost:9092",
+ "oauth_cb": "os.system",
+ }
+ with pytest.raises(ValueError, match="oauth_cb='os.system': it is not
in the"):
+ _ = hook.get_conn
+ # A populated allowlist is a configuration choice, not a missing one,
so no "configure" hint.
+ mock_log.warning.assert_not_called()
+
@mock.patch("airflow.providers.apache.kafka.hooks.base.AdminClient")
@mock.patch(f"{BASEHOOK_PATCH_PATH}.get_connection")
def test_test_connection(self, mock_get_connection, admin_client, hook):
@@ -107,6 +147,7 @@ class TestKafkaBaseHook:
mock_admin_instance.list_topics.assert_called_once_with(timeout=TIMEOUT)
assert connection == (False, "Failed to establish connection.")
+ @conf_vars(CALLBACK_ALLOWLIST)
@mock.patch("airflow.providers.apache.kafka.hooks.base.AdminClient")
@mock.patch(f"{BASEHOOK_PATCH_PATH}.get_connection")
def test_test_connection_resolves_callbacks(self, mock_get_connection,
admin_client, hook):
@@ -119,6 +160,20 @@ class TestKafkaBaseHook:
assert hook.test_connection() == (True, "Connection successful.")
admin_client.assert_called_once_with({"bootstrap.servers":
"localhost:9092", "error_cb": json.loads})
+ @mock.patch("airflow.providers.apache.kafka.hooks.base.AdminClient")
+ @mock.patch(f"{BASEHOOK_PATCH_PATH}.get_connection")
+ def test_test_connection_reports_refused_callback(self,
mock_get_connection, admin_client, hook):
+ # A callback outside the allowlist must surface as a failed connection
test, not build a client.
+ mock_get_connection.return_value.extra_dejson = {
+ "bootstrap.servers": "localhost:9092",
+ "oauth_cb": "os.system",
+ }
+ is_success, message = hook.test_connection()
+ assert is_success is False
+ assert "Refusing to resolve Kafka callback" in message
+ assert "callback_allowlist" in message
+ admin_client.assert_not_called()
+
@mock.patch("airflow.providers.apache.kafka.hooks.base.AdminClient")
@mock.patch(f"{BASEHOOK_PATCH_PATH}.get_connection")
def test_test_connection_exception(self, mock_get_connection,
admin_client, hook):
diff --git
a/providers/apache/kafka/tests/unit/apache/kafka/hooks/test_consume.py
b/providers/apache/kafka/tests/unit/apache/kafka/hooks/test_consume.py
index 4036980c626..bc48fa4dfae 100644
--- a/providers/apache/kafka/tests/unit/apache/kafka/hooks/test_consume.py
+++ b/providers/apache/kafka/tests/unit/apache/kafka/hooks/test_consume.py
@@ -27,6 +27,8 @@ from airflow.models import Connection
# Import Hook
from airflow.providers.apache.kafka.hooks.consume import KafkaConsumerHook,
error_callback
+from tests_common.test_utils.config import conf_vars
+
class TestConsumerHook:
"""
@@ -68,6 +70,7 @@ class TestConsumerHook:
assert consumer == mock_consumer.return_value
mock_consumer.return_value.subscribe.assert_called_once_with(["test_1"])
+ @conf_vars({("apache_kafka", "callback_allowlist"): "json.loads"})
@patch("airflow.providers.apache.kafka.hooks.consume.Consumer")
def
test_user_error_cb_from_connection_resolved_and_not_overridden_by_default(
self, mock_consumer, create_connection_without_db
diff --git
a/providers/apache/kafka/tests/unit/apache/kafka/hooks/test_produce.py
b/providers/apache/kafka/tests/unit/apache/kafka/hooks/test_produce.py
index e8f8cd8f667..6c35eeb606f 100644
--- a/providers/apache/kafka/tests/unit/apache/kafka/hooks/test_produce.py
+++ b/providers/apache/kafka/tests/unit/apache/kafka/hooks/test_produce.py
@@ -26,6 +26,8 @@ from confluent_kafka.admin import AdminClient
from airflow.models import Connection
from airflow.providers.apache.kafka.hooks.produce import KafkaProducerHook
+from tests_common.test_utils.config import conf_vars
+
log = logging.getLogger(__name__)
@@ -61,6 +63,7 @@ class TestProducerHook:
mock_client.return_value = mock_client_spec
assert self.hook.get_producer() == self.hook.get_conn
+ @conf_vars({("apache_kafka", "callback_allowlist"): "json.dumps"})
@patch("airflow.providers.apache.kafka.hooks.produce.Producer")
def test_connection_callback_resolved_from_dotted_path(self,
mock_producer, create_connection_without_db):
# A dotted-path ``oauth_cb`` on the connection extras is resolved to
the callable