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"),

Reply via email to