This is an automated email from the ASF dual-hosted git repository.
potiuk pushed a commit to branch v3-3-test
in repository https://gitbox.apache.org/repos/asf/airflow.git
The following commit(s) were added to refs/heads/v3-3-test by this push:
new 20c78760738 [v3-3-test] Honor FORWARDED_ALLOW_IPS when the API server
runs under gunicorn (#71429) (#71708)
20c78760738 is described below
commit 20c787607386257131767ebeaba20f95461197f5
Author: github-actions[bot]
<41898282+github-actions[bot]@users.noreply.github.com>
AuthorDate: Mon Aug 17 21:41:40 2026 +0200
[v3-3-test] Honor FORWARDED_ALLOW_IPS when the API server runs under
gunicorn (#71429) (#71708)
* Honor FORWARDED_ALLOW_IPS when the API server runs under gunicorn
Running behind a TLS-terminating proxy is documented as `--proxy-headers`
plus `FORWARDED_ALLOW_IPS` to say which proxies may be trusted, and that is
what the uvicorn server type does. The gunicorn server type forced the
trust list to every address instead, so an operator who narrowed it to
their proxy silently got a server that accepted X-Forwarded-* from anyone
able to reach it directly -- including the X-Forwarded-Proto that decides
whether session cookies are issued with the Secure attribute.
Both server types now follow the documented contract.
related: #47878
* Delete airflow-core/newsfragments/71429.significant.rst
* Warn about the wide gunicorn proxy-header default instead of tightening it
Deployments whose proxy is not on the API server's host, and which never set
FORWARDED_ALLOW_IPS, are relying on gunicorn being handed a trust list of
every address. Reading the variable where it is set already gives operators
back the control they thought they had; taking the wide default away from
everyone else belongs in a major version.
The reverse-proxy guide named uvicorn throughout, from when it was the only
option.
* Re-run CI after a transient go-mod-tidy download failure
(cherry picked from commit d4581aecace0d7874fef1091cff08ca8edfe042d)
Co-authored-by: rjgoyln <[email protected]>
---
airflow-core/docs/howto/run-behind-proxy.rst | 8 +++-
.../src/airflow/api_fastapi/gunicorn_app.py | 18 ++++++++-
.../unit/cli/commands/test_gunicorn_monitor.py | 43 ++++++++++++++++++++--
3 files changed, 62 insertions(+), 7 deletions(-)
diff --git a/airflow-core/docs/howto/run-behind-proxy.rst
b/airflow-core/docs/howto/run-behind-proxy.rst
index 64675e33e29..419b8506d4b 100644
--- a/airflow-core/docs/howto/run-behind-proxy.rst
+++ b/airflow-core/docs/howto/run-behind-proxy.rst
@@ -56,10 +56,14 @@ To do so, you need to set the following setting in your
``airflow.cfg``::
add_header Content-Security-Policy "frame-ancestors 'self';";
-- Use ``--proxy-headers`` CLI flag to tell Uvicorn to respect these headers:
``airflow api-server --proxy-headers``
+- The API server runs on one of two application servers, selected with the
``[api] server_type``
+ option: `Uvicorn <https://www.uvicorn.org>`_, the default, or `Gunicorn
<https://gunicorn.org>`_
+ with Uvicorn workers. Both read the settings below.
+
+- Use ``--proxy-headers`` CLI flag to tell the application server to respect
these headers: ``airflow api-server --proxy-headers``
- If your proxy server is not on the same host (or in the same docker
container) as Airflow, then you will need to
- set the ``FORWARDED_ALLOW_IPS`` environment variable so Uvicorn knows who to
trust this header from. See
+ set the ``FORWARDED_ALLOW_IPS`` environment variable so the application
server knows who to trust this header from. See
`Uvicorn's docs
<https://www.uvicorn.org/deployment/#proxies-and-forwarded-headers>`_. For the
full options you can pass here.
(Please note the ``--forwarded-allow-ips`` CLI option does not exist in
Airflow.)
diff --git a/airflow-core/src/airflow/api_fastapi/gunicorn_app.py
b/airflow-core/src/airflow/api_fastapi/gunicorn_app.py
index 91330de9043..9915d0a4358 100644
--- a/airflow-core/src/airflow/api_fastapi/gunicorn_app.py
+++ b/airflow-core/src/airflow/api_fastapi/gunicorn_app.py
@@ -30,9 +30,11 @@ The pattern follows gunicorn's recommended extension
approach:
from __future__ import annotations
+import os
import signal
import sys
import time
+import warnings
from typing import TYPE_CHECKING, Any
import structlog
@@ -42,6 +44,7 @@ from gunicorn.glogging import Logger as GunicornLogger
from uvicorn.workers import UvicornWorker
from airflow.configuration import conf
+from airflow.exceptions import RemovedInAirflow4Warning
if TYPE_CHECKING:
from ssl import VerifyMode
@@ -286,7 +289,20 @@ def create_gunicorn_app(
if ssl_cert_reqs is not None:
options["cert_reqs"] = ssl_cert_reqs
- if proxy_headers:
+ if not proxy_headers:
+ # ``UvicornWorker`` leaves uvicorn's ``proxy_headers`` at its default
of True, so
+ # trusting nobody is the only way to keep the worker off X-Forwarded-*.
+ options["forwarded_allow_ips"] = ""
+ elif "FORWARDED_ALLOW_IPS" not in os.environ:
+ # Deployments that never set the variable are still relying on this
server type
+ # trusting every client, so keep that until the next major version.
+ warnings.warn(
+ "Under gunicorn the API server trusts X-Forwarded-* from all
clients by default. "
+ "Set FORWARDED_ALLOW_IPS to restrict trusted proxies; this default
will become "
+ "restrictive in Airflow 4.",
+ category=RemovedInAirflow4Warning,
+ stacklevel=2,
+ )
options["forwarded_allow_ips"] = "*"
return AirflowGunicornApp(options)
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 3cd9c3e7491..01374f4177e 100644
--- a/airflow-core/tests/unit/cli/commands/test_gunicorn_monitor.py
+++ b/airflow-core/tests/unit/cli/commands/test_gunicorn_monitor.py
@@ -23,6 +23,8 @@ from unittest import mock
import pytest
from gunicorn.config import Config
+NOT_SET = object()
+
class TestAirflowArbiter:
"""Tests for the AirflowArbiter class."""
@@ -441,22 +443,55 @@ class TestCreateGunicornApp:
assert options["ca_certs"] == "/path/to/ca.crt"
assert options["cert_reqs"] == 1
- def test_create_app_with_proxy_headers(self):
- """Test creating an app with proxy headers enabled."""
+ @pytest.mark.parametrize(
+ ("proxy_headers", "forwarded_allow_ips", "expected_trusted"),
+ [
+ pytest.param(True, "10.0.0.1", NOT_SET, id="enabled defers to
FORWARDED_ALLOW_IPS"),
+ pytest.param(False, None, "", id="disabled trusts nobody"),
+ ],
+ )
+ def test_create_app_proxy_header_trust(
+ self, monkeypatch, proxy_headers, forwarded_allow_ips, expected_trusted
+ ):
+ """An operator who set FORWARDED_ALLOW_IPS gets it honoured instead of
overridden."""
from airflow.api_fastapi.gunicorn_app import create_gunicorn_app
+ if forwarded_allow_ips is None:
+ monkeypatch.delenv("FORWARDED_ALLOW_IPS", raising=False)
+ else:
+ monkeypatch.setenv("FORWARDED_ALLOW_IPS", forwarded_allow_ips)
+
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,
- proxy_headers=True,
+ proxy_headers=proxy_headers,
)
options = mock_app_class.call_args[0][0]
- assert options["forwarded_allow_ips"] == "*"
+ assert options.get("forwarded_allow_ips", NOT_SET) ==
expected_trusted
+
+ def
test_create_app_proxy_headers_without_forwarded_allow_ips_is_deprecated(self,
monkeypatch):
+ """Trusting every client stays the default for now, but is on its way
out."""
+ from airflow.api_fastapi.gunicorn_app import create_gunicorn_app
+ from airflow.exceptions import RemovedInAirflow4Warning
+
+ monkeypatch.delenv("FORWARDED_ALLOW_IPS", raising=False)
+
+ with mock.patch("airflow.api_fastapi.gunicorn_app.AirflowGunicornApp")
as mock_app_class:
+ with pytest.warns(RemovedInAirflow4Warning,
match="FORWARDED_ALLOW_IPS"):
+ create_gunicorn_app(
+ host="0.0.0.0",
+ port=8080,
+ num_workers=4,
+ worker_timeout=120,
+ proxy_headers=True,
+ )
+
+ assert mock_app_class.call_args[0][0]["forwarded_allow_ips"] == "*"
def test_create_app_never_sets_accesslog(self):
"""accesslog is never set; HttpAccessLogMiddleware handles HTTP access
logging."""