This is an automated email from the ASF dual-hosted git repository.
pierrejeambrun 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 e88a264e156 Add SSL cipher list option to the api server (#71645)
e88a264e156 is described below
commit e88a264e1564fe0ef5fd7c191b7e32c01f7ade43
Author: Jyun-An Chen <[email protected]>
AuthorDate: Tue Sep 8 18:53:11 2026 +0800
Add SSL cipher list option to the api server (#71645)
Deployments with a TLS policy that requires pinning an explicit set of
cipher suites have no way to express it: the api server exposes the
certificate, key, CA file and client-verification mode, but not the cipher
list, leaving it at whatever Python's ssl module defaults to.
---
.../src/airflow/api_fastapi/gunicorn_app.py | 4 ++
airflow-core/src/airflow/cli/cli_config.py | 6 +++
.../src/airflow/cli/commands/api_server_command.py | 14 +++++++
.../src/airflow/config_templates/config.yml | 10 +++++
.../unit/cli/commands/test_api_server_command.py | 43 +++++++++++++++++++++
.../unit/cli/commands/test_gunicorn_monitor.py | 44 ++++++++++++++++++++++
6 files changed, 121 insertions(+)
diff --git a/airflow-core/src/airflow/api_fastapi/gunicorn_app.py
b/airflow-core/src/airflow/api_fastapi/gunicorn_app.py
index 9915d0a4358..2a7f99bef4b 100644
--- a/airflow-core/src/airflow/api_fastapi/gunicorn_app.py
+++ b/airflow-core/src/airflow/api_fastapi/gunicorn_app.py
@@ -250,6 +250,7 @@ def create_gunicorn_app(
ssl_key: str | None = None,
ssl_ca_file: str | None = None,
ssl_cert_reqs: VerifyMode | None = None,
+ ssl_ciphers: str | None = None,
log_level: str = "info",
proxy_headers: bool = False,
) -> AirflowGunicornApp:
@@ -264,6 +265,7 @@ def create_gunicorn_app(
:param ssl_key: Path to SSL key file
:param ssl_ca_file: Path to the SSL CA certs file
:param ssl_cert_reqs: SSL client certificate requirements
+ :param ssl_ciphers: OpenSSL cipher list; ``None`` keeps the Python default
:param log_level: Log level (debug, info, warning, error, critical)
:param proxy_headers: Whether to trust proxy headers
"""
@@ -288,6 +290,8 @@ def create_gunicorn_app(
options["ca_certs"] = ssl_ca_file
if ssl_cert_reqs is not None:
options["cert_reqs"] = ssl_cert_reqs
+ if ssl_ciphers:
+ options["ciphers"] = ssl_ciphers
if not proxy_headers:
# ``UvicornWorker`` leaves uvicorn's ``proxy_headers`` at its default
of True, so
diff --git a/airflow-core/src/airflow/cli/cli_config.py
b/airflow-core/src/airflow/cli/cli_config.py
index f112cfcf9c2..35088c129b9 100644
--- a/airflow-core/src/airflow/cli/cli_config.py
+++ b/airflow-core/src/airflow/cli/cli_config.py
@@ -796,6 +796,11 @@ ARG_SSL_CERT_REQS = Arg(
help="(Optional) Set certificate verification options.",
choices=("none", "optional", "required"),
)
+ARG_SSL_CIPHERS = Arg(
+ ("--ssl-ciphers",),
+ default=conf.get("api", "ssl_ciphers", fallback=""),
+ help="(Optional) OpenSSL cipher list to use when SSL is enabled.",
+)
ARG_DEV = Arg(("-d", "--dev"), help="Start in development mode with hot-reload
enabled", action="store_true")
# scheduler
@@ -2214,6 +2219,7 @@ core_commands: list[CLICommand] = [
ARG_SSL_KEY,
ARG_SSL_CA_FILE,
ARG_SSL_CERT_REQS,
+ ARG_SSL_CIPHERS,
ARG_DEV,
ARG_API_SERVER_ALLOW_PROXY_FORWARDING,
),
diff --git a/airflow-core/src/airflow/cli/commands/api_server_command.py
b/airflow-core/src/airflow/cli/commands/api_server_command.py
index 9bf3a0df1f0..0700f344329 100644
--- a/airflow-core/src/airflow/cli/commands/api_server_command.py
+++ b/airflow-core/src/airflow/cli/commands/api_server_command.py
@@ -78,6 +78,7 @@ def _run_api_server_with_gunicorn(
ssl_key=ssl_key,
ssl_ca_file=ssl_ca_file,
ssl_cert_reqs=_ssl_cert_reqs(args),
+ ssl_ciphers=_ssl_ciphers(args),
log_level=log_level,
proxy_headers=proxy_headers,
)
@@ -123,6 +124,7 @@ def _run_api_server_with_uvicorn(
"ssl_certfile": ssl_cert,
"ssl_ca_certs": ssl_ca_file,
"ssl_cert_reqs": _ssl_cert_reqs(args),
+ "ssl_ciphers": _ssl_ciphers(args),
# HttpAccessLogMiddleware handles access logging; disable uvicorn's
built-in access log.
"access_log": False,
"log_level": uvicorn_log_level,
@@ -290,3 +292,15 @@ def _ssl_cert_reqs(cli_arguments):
if cert_reqs == "optional":
return ssl.CERT_OPTIONAL
raise ValueError(f"Invalid ssl_cert_reqs option: {cert_reqs}")
+
+
+def _ssl_ciphers(cli_arguments) -> str | None:
+ ciphers = cli_arguments.ssl_ciphers
+ if not ciphers:
+ return None
+ # Reject an unusable cipher list here rather than letting the server fail
while binding the socket.
+ try:
+ ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER).set_ciphers(ciphers)
+ except ssl.SSLError as e:
+ raise AirflowConfigException(f"Invalid ssl_ciphers option {ciphers!r}:
{e}") from e
+ return ciphers
diff --git a/airflow-core/src/airflow/config_templates/config.yml
b/airflow-core/src/airflow/config_templates/config.yml
index 444f85a2b7a..0a0dc060a0a 100644
--- a/airflow-core/src/airflow/config_templates/config.yml
+++ b/airflow-core/src/airflow/config_templates/config.yml
@@ -1901,6 +1901,16 @@ api:
type: string
example: "required"
default: "none"
+ ssl_ciphers:
+ description: |
+ OpenSSL cipher list used by the api server when SSL is enabled.
+ See
https://docs.openssl.org/master/man1/openssl-ciphers/#cipher-list-format
+ for the format. When left empty, the default cipher list of Python's
``ssl``
+ module is used.
+ version_added: 3.4.0
+ type: string
+ example: "ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384"
+ default: ""
maximum_page_limit:
description: |
Used to set the maximum page limit for API requests. If limit passed
as param
diff --git a/airflow-core/tests/unit/cli/commands/test_api_server_command.py
b/airflow-core/tests/unit/cli/commands/test_api_server_command.py
index 55c375721a1..8cc7f91e5ed 100644
--- a/airflow-core/tests/unit/cli/commands/test_api_server_command.py
+++ b/airflow-core/tests/unit/cli/commands/test_api_server_command.py
@@ -158,9 +158,29 @@ class TestCliApiServer(_CommonCLIUvicornTestClass):
"ssl_certfile": "ssl_cert_path_placeholder",
"ssl_ca_certs": "ssl_ca_file_placeholder",
"ssl_cert_reqs": ssl.CERT_REQUIRED,
+ "ssl_ciphers": None,
},
id="api-server with SSL cert and key",
),
+ pytest.param(
+ [
+ "api-server",
+ "--ssl-cert",
+ "ssl_cert_path_placeholder",
+ "--ssl-key",
+ "ssl_key_path_placeholder",
+ "--ssl-ciphers",
+ "ECDHE-RSA-AES256-GCM-SHA384",
+ ],
+ {
+ "ssl_keyfile": "ssl_key_path_placeholder",
+ "ssl_certfile": "ssl_cert_path_placeholder",
+ "ssl_ca_certs": None,
+ "ssl_cert_reqs": ssl.CERT_NONE,
+ "ssl_ciphers": "ECDHE-RSA-AES256-GCM-SHA384",
+ },
+ id="api-server with SSL ciphers",
+ ),
pytest.param(
[
"api-server",
@@ -172,6 +192,7 @@ class TestCliApiServer(_CommonCLIUvicornTestClass):
"ssl_certfile": None,
"ssl_ca_certs": None,
"ssl_cert_reqs": ssl.CERT_NONE,
+ "ssl_ciphers": None,
"log_config": "my_log_config.yaml",
},
id="api-server with log config",
@@ -261,6 +282,7 @@ class TestCliApiServer(_CommonCLIUvicornTestClass):
ssl_certfile=None,
ssl_ca_certs=None,
ssl_cert_reqs=ssl.CERT_NONE,
+ ssl_ciphers=None,
access_log=False,
log_level="info",
proxy_headers=False,
@@ -342,6 +364,27 @@ class TestCliApiServer(_CommonCLIUvicornTestClass):
)
assert api_server_command._get_ssl_filepaths(args) == (str(cert_path),
str(key_path), str(ca_path))
+ @pytest.mark.parametrize(
+ ("ssl_arguments", "expected"),
+ [
+ pytest.param([], None, id="unset"),
+ pytest.param(["--ssl-ciphers", ""], None, id="empty string"),
+ pytest.param(
+ ["--ssl-ciphers", "ECDHE-RSA-AES256-GCM-SHA384"],
+ "ECDHE-RSA-AES256-GCM-SHA384",
+ id="valid cipher list",
+ ),
+ ],
+ )
+ def test_ssl_ciphers(self, ssl_arguments, expected):
+ args = self.parser.parse_args(["api-server"] + ssl_arguments)
+ assert api_server_command._ssl_ciphers(args) == expected
+
+ def test_ssl_ciphers_rejects_unusable_cipher_list(self):
+ args = self.parser.parse_args(["api-server", "--ssl-ciphers",
"NOT-A-REAL-CIPHER"])
+ with pytest.raises(AirflowConfigException, match="Invalid ssl_ciphers
option 'NOT-A-REAL-CIPHER'"):
+ api_server_command._ssl_ciphers(args)
+
@pytest.fixture
def ssl_cert_key_and_ca(self, tmp_path):
cert_path, key_path, ca_path = tmp_path / "_.crt", tmp_path / "_.key",
tmp_path / "ca.crt"
diff --git a/airflow-core/tests/unit/cli/commands/test_gunicorn_monitor.py
b/airflow-core/tests/unit/cli/commands/test_gunicorn_monitor.py
index 01374f4177e..0499d14bb4b 100644
--- a/airflow-core/tests/unit/cli/commands/test_gunicorn_monitor.py
+++ b/airflow-core/tests/unit/cli/commands/test_gunicorn_monitor.py
@@ -442,6 +442,50 @@ class TestCreateGunicornApp:
assert options["keyfile"] == "/path/to/key.pem"
assert options["ca_certs"] == "/path/to/ca.crt"
assert options["cert_reqs"] == 1
+ assert "ciphers" not in options
+
+ @pytest.mark.parametrize(
+ ("ssl_ciphers", "expected"),
+ [
+ pytest.param("ECDHE-RSA-AES256-GCM-SHA384",
"ECDHE-RSA-AES256-GCM-SHA384", id="explicit list"),
+ pytest.param("", None, id="empty string keeps python default"),
+ pytest.param(None, None, id="unset keeps python default"),
+ ],
+ )
+ def test_create_app_with_ssl_ciphers(self, ssl_ciphers, expected):
+ from airflow.api_fastapi.gunicorn_app import create_gunicorn_app
+
+ with mock.patch("airflow.api_fastapi.gunicorn_app.AirflowGunicornApp")
as mock_app_class:
+ create_gunicorn_app(
+ host="0.0.0.0",
+ port=8443,
+ num_workers=4,
+ worker_timeout=120,
+ ssl_cert="/path/to/cert.pem",
+ ssl_key="/path/to/key.pem",
+ ssl_ciphers=ssl_ciphers,
+ )
+
+ options = mock_app_class.call_args[0][0]
+
+ assert options.get("ciphers") == expected
+
+ def test_create_app_with_ssl_ciphers_without_cert_is_ignored(self):
+ """Ciphers only apply to an SSL listener, so they must not leak into a
plain-HTTP config."""
+ from airflow.api_fastapi.gunicorn_app import create_gunicorn_app
+
+ with mock.patch("airflow.api_fastapi.gunicorn_app.AirflowGunicornApp")
as mock_app_class:
+ create_gunicorn_app(
+ host="0.0.0.0",
+ port=8080,
+ num_workers=4,
+ worker_timeout=120,
+ ssl_ciphers="ECDHE-RSA-AES256-GCM-SHA384",
+ )
+
+ options = mock_app_class.call_args[0][0]
+
+ assert "ciphers" not in options
@pytest.mark.parametrize(
("proxy_headers", "forwarded_allow_ips", "expected_trusted"),