CVE-2017-17835
Description
In Apache Airflow 1.8.2 and earlier, a CSRF vulnerability allowed for a remote command injection on a default install of Airflow.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
A CSRF vulnerability in Apache Airflow 1.8.2 and earlier allows remote command injection on default installations.
Vulnerability
In Apache Airflow versions 1.8.2 and earlier, a Cross-Site Request Forgery (CSRF) vulnerability exists due to missing CSRF protection on state-changing endpoints. This allows an attacker to perform actions on behalf of an authenticated user without their consent. The vulnerability is present in the default installation, specifically in the web interface. [1][2][3][4]
Exploitation
An attacker can craft a malicious page or link that, when visited by an authenticated Airflow user, triggers a CSRF request to a vulnerable endpoint. The attacker does not need prior authentication but relies on the victim's session. The attack requires user interaction (the victim must click a link or visit a page). The vulnerability is exploitable over the network via HTTP requests. [2][3]
Impact
Successful exploitation allows remote command injection on the Airflow server, potentially leading to full compromise of the system. The attacker can execute arbitrary commands with the privileges of the Airflow web server process, leading to information disclosure, data manipulation, or denial of service. [2][3]
Mitigation
The fix was committed in commit dca5e7d and is included in Apache Airflow 1.9.0, released in 2018. Users should upgrade to Airflow 1.9.0 or later. For users unable to upgrade, the advisory recommends ensuring CSRF protection is enabled and not disabling authentication. [2][4]
AI Insight generated on May 22, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
apache-airflowPyPI | < 1.9.0 | 1.9.0 |
Affected products
2- Apache Software Foundation/Apache Airflowv5Range: Apache Airflow <= 1.8.2
Patches
4dca5e7d116b5[AIRFLOW-836] Use POST and CSRF for state changing endpoints
6 files changed · +75 −23
airflow/www/templates/admin/master.html+8 −0 modified@@ -38,6 +38,14 @@ alert('{{ hostname }}'); }); $('span').tooltip(); + + $.ajaxSetup({ + beforeSend: function(xhr, settings) { + if (!/^(GET|HEAD|OPTIONS|TRACE)$/i.test(settings.type) && !this.crossDomain) { + xhr.setRequestHeader("X-CSRFToken", "{{ csrf_token() }}"); + } + } + }); </script> {% endblock %}
airflow/www/templates/airflow/dag.html+2 −2 modified@@ -32,7 +32,7 @@ <h3 class="pull-left"> {% if dag.parent_dag %} <span style='color:#AAA;'>SUBDAG: </span> <span> {{ dag.dag_id }}</span> {% else %} - <input id="pause_resume" dag_id="{{ dag.dag_id }}" type="checkbox" {{ "checked" if not dag.is_paused else "" }} data-toggle="toggle" data-size="mini"> + <input id="pause_resume" dag_id="{{ dag.dag_id }}" type="checkbox" {{ "checked" if not dag.is_paused else "" }} data-toggle="toggle" data-size="mini" method="post"> <span style='color:#AAA;'>DAG: </span> <span> {{ dag.dag_id }}</span> <small class="text-muted"> {{ dag.description }} </small> {% endif %} {% if root %} @@ -364,7 +364,7 @@ <h4 class="modal-title" id="myModalLabel"> is_paused = 'false' } url = "{{ url_for('airflow.paused') }}" + '?is_paused=' + is_paused + '&dag_id=' + dag_id; - $.ajax(url); + $.post(url); }); </script>
airflow/www/templates/airflow/dags.html+2 −2 modified@@ -66,7 +66,7 @@ <h2>DAGs</h2> <!-- Column 2: Turn dag on/off --> <td> {% if dag_id in orm_dags %} - <input id="toggle-{{ dag_id }}" dag_id="{{ dag_id }}" type="checkbox" {{ "checked" if not orm_dags[dag_id].is_paused else "" }} data-toggle="toggle" data-size="mini"> + <input id="toggle-{{ dag_id }}" dag_id="{{ dag_id }}" type="checkbox" {{ "checked" if not orm_dags[dag_id].is_paused else "" }} data-toggle="toggle" data-size="mini" method="post"> {% endif %} </td> @@ -214,7 +214,7 @@ <h2>DAGs</h2> is_paused = 'false' } url = 'airflow/paused?is_paused=' + is_paused + '&dag_id=' + dag_id; - $.ajax(url); + $.post(url); }); }); $('#dags').dataTable({
airflow/www/templates/airflow/query.html+11 −7 modified@@ -29,12 +29,12 @@ {% block body %} <h2>Ad Hoc Query</h2> - <form method="get" id="query_form"> + <form method="post" id="query_form"> <div class="form-inline"> <input name="_csrf_token" type="hidden" value="{{ csrf_token() }}"> {{ form.conn_id }} - <input type="submit" class="btn btn-default" value="Run!"> - <input type="button" class="btn btn-default" value=".csv" id="csv"> + <input type="submit" class="btn btn-default" value="Run!" id="submit_without_csv"> + <input type="submit" class="btn btn-default" value=".csv" id="submit_with_csv"> <span id="results"></span><br> <div id='ace_container'> {{ form.sql(rows=10) }} @@ -71,12 +71,16 @@ <h2>Ad Hoc Query</h2> }); $('select').addClass("form-control"); sync(); - $("#query_form").submit(function(event){ + $("#submit_without_csv").submit(function(event){ $("#results").html("<img width='25'src='{{ url_for('static', filename='loading.gif') }}'>"); }); - $("#csv").on("click", function(){ - window.location += '&csv=true'; - }) + $("#submit_with_csv").click(function(){ + $("#csv_value").remove(); + $("#query_form").append('<input name="csv" type="hidden" value="true" id="csv_value">'); + }); + $("#submit_without_csv").click(function(){ + $("#csv_value").remove(); + }); }); </script> {% endblock %}
airflow/www/views.py+5 −5 modified@@ -1614,7 +1614,7 @@ def landing_times(self): form=form, ) - @expose('/paused') + @expose('/paused', methods=['POST']) @login_required @wwwutils.action_logging def paused(self): @@ -1883,7 +1883,7 @@ def index(self): class QueryView(wwwutils.DataProfilingMixin, BaseView): - @expose('/') + @expose('/', methods=['POST', 'GET']) @wwwutils.gzipped def query(self): session = settings.Session() @@ -1892,9 +1892,9 @@ def query(self): session.expunge_all() db_choices = list( ((db.conn_id, db.conn_id) for db in dbs if db.get_hook())) - conn_id_str = request.args.get('conn_id') - csv = request.args.get('csv') == "true" - sql = request.args.get('sql') + conn_id_str = request.form.get('conn_id') + csv = request.form.get('csv') == "true" + sql = request.form.get('sql') class QueryForm(Form): conn_id = SelectField("Layout", choices=db_choices)
tests/core.py+47 −7 modified@@ -1426,6 +1426,44 @@ def test_variables(self): os.remove('variables1.json') os.remove('variables2.json') +class CSRFTests(unittest.TestCase): + def setUp(self): + configuration.load_test_config() + configuration.conf.set("webserver", "authenticate", "False") + configuration.conf.set("webserver", "expose_config", "True") + app = application.create_app() + app.config['TESTING'] = True + self.app = app.test_client() + + self.dagbag = models.DagBag( + dag_folder=DEV_NULL, include_examples=True) + self.dag_bash = self.dagbag.dags['example_bash_operator'] + self.runme_0 = self.dag_bash.get_task('runme_0') + + def get_csrf(self, response): + tree = html.fromstring(response.data) + form = tree.find('.//form') + + return form.find('.//input[@name="_csrf_token"]').value + + def test_csrf_rejection(self): + endpoints = ([ + "/admin/queryview/", + "/admin/airflow/paused?dag_id=example_python_operator&is_paused=false", + ]) + for endpoint in endpoints: + response = self.app.post(endpoint) + self.assertIn('CSRF token is missing', response.data.decode('utf-8')) + + def test_csrf_acceptance(self): + response = self.app.get("/admin/queryview/") + csrf = self.get_csrf(response) + response = self.app.post("/admin/queryview/", data=dict(csrf_token=csrf)) + self.assertEqual(200, response.status_code) + + def tearDown(self): + configuration.conf.set("webserver", "expose_config", "False") + self.dag_bash.clear(start_date=DEFAULT_DATE, end_date=datetime.now()) class WebUiTests(unittest.TestCase): def setUp(self): @@ -1434,6 +1472,7 @@ def setUp(self): configuration.conf.set("webserver", "expose_config", "True") app = application.create_app() app.config['TESTING'] = True + app.config['WTF_CSRF_METHODS'] = [] self.app = app.test_client() self.dagbag = models.DagBag(include_examples=True) @@ -1463,12 +1502,12 @@ def test_index(self): def test_query(self): response = self.app.get('/admin/queryview/') - assert "Ad Hoc Query" in response.data.decode('utf-8') - response = self.app.get( - "/admin/queryview/?" - "conn_id=airflow_db&" - "sql=SELECT+COUNT%281%29+as+TEST+FROM+task_instance") - assert "TEST" in response.data.decode('utf-8') + self.assertIn("Ad Hoc Query", response.data.decode('utf-8')) + response = self.app.post( + "/admin/queryview/", data=dict( + conn_id="airflow_db", + sql="SELECT+COUNT%281%29+as+TEST+FROM+task_instance")) + self.assertIn("TEST", response.data.decode('utf-8')) def test_health(self): response = self.app.get('/health') @@ -1582,9 +1621,10 @@ def test_dag_views(self): response = self.app.get( "/admin/airflow/refresh?dag_id=example_bash_operator") response = self.app.get("/admin/airflow/refresh_all") - response = self.app.get( + response = self.app.post( "/admin/airflow/paused?" "dag_id=example_python_operator&is_paused=false") + self.assertIn("OK", response.data.decode('utf-8')) response = self.app.get("/admin/xcom", follow_redirects=True) assert "Xcoms" in response.data.decode('utf-8')
c9dc9263986c[AIRFLOW-836] Use POST and CSRF for state changing endpoints
6 files changed · +73 −21
airflow/www/templates/admin/master.html+8 −0 modified@@ -38,6 +38,14 @@ alert('{{ hostname }}'); }); $('span').tooltip(); + + $.ajaxSetup({ + beforeSend: function(xhr, settings) { + if (!/^(GET|HEAD|OPTIONS|TRACE)$/i.test(settings.type) && !this.crossDomain) { + xhr.setRequestHeader("X-CSRFToken", "{{ csrf_token() }}"); + } + } + }); </script> {% endblock %}
airflow/www/templates/airflow/dag.html+2 −2 modified@@ -32,7 +32,7 @@ <h3 class="pull-left"> {% if dag.parent_dag %} <span style='color:#AAA;'>SUBDAG: </span> <span> {{ dag.dag_id }}</span> {% else %} - <input id="pause_resume" dag_id="{{ dag.dag_id }}" type="checkbox" {{ "checked" if not dag.is_paused else "" }} data-toggle="toggle" data-size="mini"> + <input id="pause_resume" dag_id="{{ dag.dag_id }}" type="checkbox" {{ "checked" if not dag.is_paused else "" }} data-toggle="toggle" data-size="mini" method="post"> <span style='color:#AAA;'>DAG: </span> <span> {{ dag.dag_id }}</span> <small class="text-muted"> {{ dag.description }} </small> {% endif %} {% if root %} @@ -364,7 +364,7 @@ <h4 class="modal-title" id="myModalLabel"> is_paused = 'false' } url = "{{ url_for('airflow.paused') }}" + '?is_paused=' + is_paused + '&dag_id=' + dag_id; - $.ajax(url); + $.post(url); }); </script>
airflow/www/templates/airflow/dags.html+2 −2 modified@@ -66,7 +66,7 @@ <h2>DAGs</h2> <!-- Column 2: Turn dag on/off --> <td> {% if dag_id in orm_dags %} - <input id="toggle-{{ dag_id }}" dag_id="{{ dag_id }}" type="checkbox" {{ "checked" if not orm_dags[dag_id].is_paused else "" }} data-toggle="toggle" data-size="mini"> + <input id="toggle-{{ dag_id }}" dag_id="{{ dag_id }}" type="checkbox" {{ "checked" if not orm_dags[dag_id].is_paused else "" }} data-toggle="toggle" data-size="mini" method="post"> {% endif %} </td> @@ -214,7 +214,7 @@ <h2>DAGs</h2> is_paused = 'false' } url = 'airflow/paused?is_paused=' + is_paused + '&dag_id=' + dag_id; - $.ajax(url); + $.post(url); }); }); $('#dags').dataTable({
airflow/www/templates/airflow/query.html+11 −7 modified@@ -29,12 +29,12 @@ {% block body %} <h2>Ad Hoc Query</h2> - <form method="get" id="query_form"> + <form method="post" id="query_form"> <div class="form-inline"> <input name="_csrf_token" type="hidden" value="{{ csrf_token() }}"> {{ form.conn_id }} - <input type="submit" class="btn btn-default" value="Run!"> - <input type="button" class="btn btn-default" value=".csv" id="csv"> + <input type="submit" class="btn btn-default" value="Run!" id="submit_without_csv"> + <input type="submit" class="btn btn-default" value=".csv" id="submit_with_csv"> <span id="results"></span><br> <div id='ace_container'> {{ form.sql(rows=10) }} @@ -71,12 +71,16 @@ <h2>Ad Hoc Query</h2> }); $('select').addClass("form-control"); sync(); - $("#query_form").submit(function(event){ + $("#submit_without_csv").submit(function(event){ $("#results").html("<img width='25'src='{{ url_for('static', filename='loading.gif') }}'>"); }); - $("#csv").on("click", function(){ - window.location += '&csv=true'; - }) + $("#submit_with_csv").click(function(){ + $("#csv_value").remove(); + $("#query_form").append('<input name="csv" type="hidden" value="true" id="csv_value">'); + }); + $("#submit_without_csv").click(function(){ + $("#csv_value").remove(); + }); }); </script> {% endblock %}
airflow/www/views.py+5 −5 modified@@ -1597,7 +1597,7 @@ def landing_times(self): form=form, ) - @expose('/paused') + @expose('/paused', methods=['POST']) @login_required @wwwutils.action_logging def paused(self): @@ -1865,7 +1865,7 @@ def index(self): class QueryView(wwwutils.DataProfilingMixin, BaseView): - @expose('/') + @expose('/', methods=['POST', 'GET']) @wwwutils.gzipped def query(self): session = settings.Session() @@ -1874,9 +1874,9 @@ def query(self): session.expunge_all() db_choices = list( ((db.conn_id, db.conn_id) for db in dbs if db.get_hook())) - conn_id_str = request.args.get('conn_id') - csv = request.args.get('csv') == "true" - sql = request.args.get('sql') + conn_id_str = request.form.get('conn_id') + csv = request.form.get('csv') == "true" + sql = request.form.get('sql') class QueryForm(Form): conn_id = SelectField("Layout", choices=db_choices)
tests/core.py+45 −5 modified@@ -1407,6 +1407,44 @@ def test_variables(self): os.remove('variables1.json') os.remove('variables2.json') +class CSRFTests(unittest.TestCase): + def setUp(self): + configuration.load_test_config() + configuration.conf.set("webserver", "authenticate", "False") + configuration.conf.set("webserver", "expose_config", "True") + app = application.create_app() + app.config['TESTING'] = True + self.app = app.test_client() + + self.dagbag = models.DagBag( + dag_folder=DEV_NULL, include_examples=True) + self.dag_bash = self.dagbag.dags['example_bash_operator'] + self.runme_0 = self.dag_bash.get_task('runme_0') + + def get_csrf(self, response): + tree = html.fromstring(response.data) + form = tree.find('.//form') + + return form.find('.//input[@name="_csrf_token"]').value + + def test_csrf_rejection(self): + endpoints = ([ + "/admin/queryview/", + "/admin/airflow/paused?dag_id=example_python_operator&is_paused=false", + ]) + for endpoint in endpoints: + response = self.app.post(endpoint) + self.assertIn('CSRF token is missing', response.data.decode('utf-8')) + + def test_csrf_acceptance(self): + response = self.app.get("/admin/queryview/") + csrf = self.get_csrf(response) + response = self.app.post("/admin/queryview/", data=dict(csrf_token=csrf)) + self.assertEqual(200, response.status_code) + + def tearDown(self): + configuration.conf.set("webserver", "expose_config", "False") + self.dag_bash.clear(start_date=DEFAULT_DATE, end_date=datetime.now()) class WebUiTests(unittest.TestCase): def setUp(self): @@ -1415,6 +1453,7 @@ def setUp(self): configuration.conf.set("webserver", "expose_config", "True") app = application.create_app() app.config['TESTING'] = True + app.config['WTF_CSRF_METHODS'] = [] self.app = app.test_client() self.dagbag = models.DagBag(include_examples=True) @@ -1445,10 +1484,10 @@ def test_index(self): def test_query(self): response = self.app.get('/admin/queryview/') self.assertIn("Ad Hoc Query", response.data.decode('utf-8')) - response = self.app.get( - "/admin/queryview/?" - "conn_id=airflow_db&" - "sql=SELECT+COUNT%281%29+as+TEST+FROM+task_instance") + response = self.app.post( + "/admin/queryview/", data=dict( + conn_id="airflow_db", + sql="SELECT+COUNT%281%29+as+TEST+FROM+task_instance")) self.assertIn("TEST", response.data.decode('utf-8')) def test_health(self): @@ -1563,9 +1602,10 @@ def test_dag_views(self): response = self.app.get( "/admin/airflow/refresh?dag_id=example_bash_operator") response = self.app.get("/admin/airflow/refresh_all") - response = self.app.get( + response = self.app.post( "/admin/airflow/paused?" "dag_id=example_python_operator&is_paused=false") + self.assertIn("OK", response.data.decode('utf-8')) response = self.app.get("/admin/xcom", follow_redirects=True) self.assertIn("Xcoms", response.data.decode('utf-8'))
6aca2c2d3959[AIRFLOW-836] Use POST and CSRF for state changing endpoints
6 files changed · +73 −21
airflow/www/templates/admin/master.html+8 −0 modified@@ -38,6 +38,14 @@ alert('{{ hostname }}'); }); $('span').tooltip(); + + $.ajaxSetup({ + beforeSend: function(xhr, settings) { + if (!/^(GET|HEAD|OPTIONS|TRACE)$/i.test(settings.type) && !this.crossDomain) { + xhr.setRequestHeader("X-CSRFToken", "{{ csrf_token() }}"); + } + } + }); </script> {% endblock %}
airflow/www/templates/airflow/dag.html+2 −2 modified@@ -32,7 +32,7 @@ <h3 class="pull-left"> {% if dag.parent_dag %} <span style='color:#AAA;'>SUBDAG: </span> <span> {{ dag.dag_id }}</span> {% else %} - <input id="pause_resume" dag_id="{{ dag.dag_id }}" type="checkbox" {{ "checked" if not dag.is_paused else "" }} data-toggle="toggle" data-size="mini"> + <input id="pause_resume" dag_id="{{ dag.dag_id }}" type="checkbox" {{ "checked" if not dag.is_paused else "" }} data-toggle="toggle" data-size="mini" method="post"> <span style='color:#AAA;'>DAG: </span> <span> {{ dag.dag_id }}</span> <small class="text-muted"> {{ dag.description }} </small> {% endif %} {% if root %} @@ -364,7 +364,7 @@ <h4 class="modal-title" id="myModalLabel"> is_paused = 'false' } url = "{{ url_for('airflow.paused') }}" + '?is_paused=' + is_paused + '&dag_id=' + dag_id; - $.ajax(url); + $.post(url); }); </script>
airflow/www/templates/airflow/dags.html+2 −2 modified@@ -66,7 +66,7 @@ <h2>DAGs</h2> <!-- Column 2: Turn dag on/off --> <td> {% if dag_id in orm_dags %} - <input id="toggle-{{ dag_id }}" dag_id="{{ dag_id }}" type="checkbox" {{ "checked" if not orm_dags[dag_id].is_paused else "" }} data-toggle="toggle" data-size="mini"> + <input id="toggle-{{ dag_id }}" dag_id="{{ dag_id }}" type="checkbox" {{ "checked" if not orm_dags[dag_id].is_paused else "" }} data-toggle="toggle" data-size="mini" method="post"> {% endif %} </td> @@ -214,7 +214,7 @@ <h2>DAGs</h2> is_paused = 'false' } url = 'airflow/paused?is_paused=' + is_paused + '&dag_id=' + dag_id; - $.ajax(url); + $.post(url); }); }); $('#dags').dataTable({
airflow/www/templates/airflow/query.html+11 −7 modified@@ -29,12 +29,12 @@ {% block body %} <h2>Ad Hoc Query</h2> - <form method="get" id="query_form"> + <form method="post" id="query_form"> <div class="form-inline"> <input name="_csrf_token" type="hidden" value="{{ csrf_token() }}"> {{ form.conn_id }} - <input type="submit" class="btn btn-default" value="Run!"> - <input type="button" class="btn btn-default" value=".csv" id="csv"> + <input type="submit" class="btn btn-default" value="Run!" id="submit_without_csv"> + <input type="submit" class="btn btn-default" value=".csv" id="submit_with_csv"> <span id="results"></span><br> <div id='ace_container'> {{ form.sql(rows=10) }} @@ -71,12 +71,16 @@ <h2>Ad Hoc Query</h2> }); $('select').addClass("form-control"); sync(); - $("#query_form").submit(function(event){ + $("#submit_without_csv").submit(function(event){ $("#results").html("<img width='25'src='{{ url_for('static', filename='loading.gif') }}'>"); }); - $("#csv").on("click", function(){ - window.location += '&csv=true'; - }) + $("#submit_with_csv").click(function(){ + $("#csv_value").remove(); + $("#query_form").append('<input name="csv" type="hidden" value="true" id="csv_value">'); + }); + $("#submit_without_csv").click(function(){ + $("#csv_value").remove(); + }); }); </script> {% endblock %}
airflow/www/views.py+5 −5 modified@@ -1597,7 +1597,7 @@ def landing_times(self): form=form, ) - @expose('/paused') + @expose('/paused', methods=['POST']) @login_required @wwwutils.action_logging def paused(self): @@ -1865,7 +1865,7 @@ def index(self): class QueryView(wwwutils.DataProfilingMixin, BaseView): - @expose('/') + @expose('/', methods=['POST', 'GET']) @wwwutils.gzipped def query(self): session = settings.Session() @@ -1874,9 +1874,9 @@ def query(self): session.expunge_all() db_choices = list( ((db.conn_id, db.conn_id) for db in dbs if db.get_hook())) - conn_id_str = request.args.get('conn_id') - csv = request.args.get('csv') == "true" - sql = request.args.get('sql') + conn_id_str = request.form.get('conn_id') + csv = request.form.get('csv') == "true" + sql = request.form.get('sql') class QueryForm(Form): conn_id = SelectField("Layout", choices=db_choices)
tests/core.py+45 −5 modified@@ -1407,6 +1407,44 @@ def test_variables(self): os.remove('variables1.json') os.remove('variables2.json') +class CSRFTests(unittest.TestCase): + def setUp(self): + configuration.load_test_config() + configuration.conf.set("webserver", "authenticate", "False") + configuration.conf.set("webserver", "expose_config", "True") + app = application.create_app() + app.config['TESTING'] = True + self.app = app.test_client() + + self.dagbag = models.DagBag( + dag_folder=DEV_NULL, include_examples=True) + self.dag_bash = self.dagbag.dags['example_bash_operator'] + self.runme_0 = self.dag_bash.get_task('runme_0') + + def get_csrf(self, response): + tree = html.fromstring(response.data) + form = tree.find('.//form') + + return form.find('.//input[@name="_csrf_token"]').value + + def test_csrf_rejection(self): + endpoints = ([ + "/admin/queryview/", + "/admin/airflow/paused?dag_id=example_python_operator&is_paused=false", + ]) + for endpoint in endpoints: + response = self.app.post(endpoint) + self.assertIn('CSRF token is missing', response.data.decode('utf-8')) + + def test_csrf_acceptance(self): + response = self.app.get("/admin/queryview/") + csrf = self.get_csrf(response) + response = self.app.post("/admin/queryview/", data=dict(csrf_token=csrf)) + self.assertEqual(200, response.status_code) + + def tearDown(self): + configuration.conf.set("webserver", "expose_config", "False") + self.dag_bash.clear(start_date=DEFAULT_DATE, end_date=datetime.now()) class WebUiTests(unittest.TestCase): def setUp(self): @@ -1415,6 +1453,7 @@ def setUp(self): configuration.conf.set("webserver", "expose_config", "True") app = application.create_app() app.config['TESTING'] = True + app.config['WTF_CSRF_METHODS'] = [] self.app = app.test_client() self.dagbag = models.DagBag(include_examples=True) @@ -1445,10 +1484,10 @@ def test_index(self): def test_query(self): response = self.app.get('/admin/queryview/') self.assertIn("Ad Hoc Query", response.data.decode('utf-8')) - response = self.app.get( - "/admin/queryview/?" - "conn_id=airflow_db&" - "sql=SELECT+COUNT%281%29+as+TEST+FROM+task_instance") + response = self.app.post( + "/admin/queryview/", data=dict( + conn_id="airflow_db", + sql="SELECT+COUNT%281%29+as+TEST+FROM+task_instance")) self.assertIn("TEST", response.data.decode('utf-8')) def test_health(self): @@ -1563,9 +1602,10 @@ def test_dag_views(self): response = self.app.get( "/admin/airflow/refresh?dag_id=example_bash_operator") response = self.app.get("/admin/airflow/refresh_all") - response = self.app.get( + response = self.app.post( "/admin/airflow/paused?" "dag_id=example_python_operator&is_paused=false") + self.assertIn("OK", response.data.decode('utf-8')) response = self.app.get("/admin/xcom", follow_redirects=True) self.assertIn("Xcoms", response.data.decode('utf-8'))
673026c74041[AIRFLOW-836] Use POST and CSRF for state changing endpoints
6 files changed · +73 −21
airflow/www/templates/admin/master.html+8 −0 modified@@ -38,6 +38,14 @@ alert('{{ hostname }}'); }); $('span').tooltip(); + + $.ajaxSetup({ + beforeSend: function(xhr, settings) { + if (!/^(GET|HEAD|OPTIONS|TRACE)$/i.test(settings.type) && !this.crossDomain) { + xhr.setRequestHeader("X-CSRFToken", "{{ csrf_token() }}"); + } + } + }); </script> {% endblock %}
airflow/www/templates/airflow/dag.html+2 −2 modified@@ -32,7 +32,7 @@ <h3 class="pull-left"> {% if dag.parent_dag %} <span style='color:#AAA;'>SUBDAG: </span> <span> {{ dag.dag_id }}</span> {% else %} - <input id="pause_resume" dag_id="{{ dag.dag_id }}" type="checkbox" {{ "checked" if not dag.is_paused else "" }} data-toggle="toggle" data-size="mini"> + <input id="pause_resume" dag_id="{{ dag.dag_id }}" type="checkbox" {{ "checked" if not dag.is_paused else "" }} data-toggle="toggle" data-size="mini" method="post"> <span style='color:#AAA;'>DAG: </span> <span> {{ dag.dag_id }}</span> <small class="text-muted"> {{ dag.description }} </small> {% endif %} {% if root %} @@ -364,7 +364,7 @@ <h4 class="modal-title" id="myModalLabel"> is_paused = 'false' } url = "{{ url_for('airflow.paused') }}" + '?is_paused=' + is_paused + '&dag_id=' + dag_id; - $.ajax(url); + $.post(url); }); </script>
airflow/www/templates/airflow/dags.html+2 −2 modified@@ -66,7 +66,7 @@ <h2>DAGs</h2> <!-- Column 2: Turn dag on/off --> <td> {% if dag_id in orm_dags %} - <input id="toggle-{{ dag_id }}" dag_id="{{ dag_id }}" type="checkbox" {{ "checked" if not orm_dags[dag_id].is_paused else "" }} data-toggle="toggle" data-size="mini"> + <input id="toggle-{{ dag_id }}" dag_id="{{ dag_id }}" type="checkbox" {{ "checked" if not orm_dags[dag_id].is_paused else "" }} data-toggle="toggle" data-size="mini" method="post"> {% endif %} </td> @@ -214,7 +214,7 @@ <h2>DAGs</h2> is_paused = 'false' } url = 'airflow/paused?is_paused=' + is_paused + '&dag_id=' + dag_id; - $.ajax(url); + $.post(url); }); }); $('#dags').dataTable({
airflow/www/templates/airflow/query.html+11 −7 modified@@ -29,12 +29,12 @@ {% block body %} <h2>Ad Hoc Query</h2> - <form method="get" id="query_form"> + <form method="post" id="query_form"> <div class="form-inline"> <input name="_csrf_token" type="hidden" value="{{ csrf_token() }}"> {{ form.conn_id }} - <input type="submit" class="btn btn-default" value="Run!"> - <input type="button" class="btn btn-default" value=".csv" id="csv"> + <input type="submit" class="btn btn-default" value="Run!" id="submit_without_csv"> + <input type="submit" class="btn btn-default" value=".csv" id="submit_with_csv"> <span id="results"></span><br> <div id='ace_container'> {{ form.sql(rows=10) }} @@ -71,12 +71,16 @@ <h2>Ad Hoc Query</h2> }); $('select').addClass("form-control"); sync(); - $("#query_form").submit(function(event){ + $("#submit_without_csv").submit(function(event){ $("#results").html("<img width='25'src='{{ url_for('static', filename='loading.gif') }}'>"); }); - $("#csv").on("click", function(){ - window.location += '&csv=true'; - }) + $("#submit_with_csv").click(function(){ + $("#csv_value").remove(); + $("#query_form").append('<input name="csv" type="hidden" value="true" id="csv_value">'); + }); + $("#submit_without_csv").click(function(){ + $("#csv_value").remove(); + }); }); </script> {% endblock %}
airflow/www/views.py+5 −5 modified@@ -1596,7 +1596,7 @@ def landing_times(self): form=form, ) - @expose('/paused') + @expose('/paused', methods=['POST']) @login_required @wwwutils.action_logging def paused(self): @@ -1864,7 +1864,7 @@ def index(self): class QueryView(wwwutils.DataProfilingMixin, BaseView): - @expose('/') + @expose('/', methods=['POST', 'GET']) @wwwutils.gzipped def query(self): session = settings.Session() @@ -1873,9 +1873,9 @@ def query(self): session.expunge_all() db_choices = list( ((db.conn_id, db.conn_id) for db in dbs if db.get_hook())) - conn_id_str = request.args.get('conn_id') - csv = request.args.get('csv') == "true" - sql = request.args.get('sql') + conn_id_str = request.form.get('conn_id') + csv = request.form.get('csv') == "true" + sql = request.form.get('sql') class QueryForm(Form): conn_id = SelectField("Layout", choices=db_choices)
tests/core.py+45 −5 modified@@ -1407,6 +1407,44 @@ def test_variables(self): os.remove('variables1.json') os.remove('variables2.json') +class CSRFTests(unittest.TestCase): + def setUp(self): + configuration.load_test_config() + configuration.conf.set("webserver", "authenticate", "False") + configuration.conf.set("webserver", "expose_config", "True") + app = application.create_app() + app.config['TESTING'] = True + self.app = app.test_client() + + self.dagbag = models.DagBag( + dag_folder=DEV_NULL, include_examples=True) + self.dag_bash = self.dagbag.dags['example_bash_operator'] + self.runme_0 = self.dag_bash.get_task('runme_0') + + def get_csrf(self, response): + tree = html.fromstring(response.data) + form = tree.find('.//form') + + return form.find('.//input[@name="_csrf_token"]').value + + def test_csrf_rejection(self): + endpoints = ([ + "/admin/queryview/", + "/admin/airflow/paused?dag_id=example_python_operator&is_paused=false", + ]) + for endpoint in endpoints: + response = self.app.post(endpoint) + self.assertIn('CSRF token is missing', response.data.decode('utf-8')) + + def test_csrf_acceptance(self): + response = self.app.get("/admin/queryview/") + csrf = self.get_csrf(response) + response = self.app.post("/admin/queryview/", data=dict(csrf_token=csrf)) + self.assertEqual(200, response.status_code) + + def tearDown(self): + configuration.conf.set("webserver", "expose_config", "False") + self.dag_bash.clear(start_date=DEFAULT_DATE, end_date=datetime.now()) class WebUiTests(unittest.TestCase): def setUp(self): @@ -1415,6 +1453,7 @@ def setUp(self): configuration.conf.set("webserver", "expose_config", "True") app = application.create_app() app.config['TESTING'] = True + app.config['WTF_CSRF_METHODS'] = [] self.app = app.test_client() self.dagbag = models.DagBag(include_examples=True) @@ -1445,10 +1484,10 @@ def test_index(self): def test_query(self): response = self.app.get('/admin/queryview/') self.assertIn("Ad Hoc Query", response.data.decode('utf-8')) - response = self.app.get( - "/admin/queryview/?" - "conn_id=airflow_db&" - "sql=SELECT+COUNT%281%29+as+TEST+FROM+task_instance") + response = self.app.post( + "/admin/queryview/", data=dict( + conn_id="airflow_db", + sql="SELECT+COUNT%281%29+as+TEST+FROM+task_instance")) self.assertIn("TEST", response.data.decode('utf-8')) def test_health(self): @@ -1563,9 +1602,10 @@ def test_dag_views(self): response = self.app.get( "/admin/airflow/refresh?dag_id=example_bash_operator") response = self.app.get("/admin/airflow/refresh_all") - response = self.app.get( + response = self.app.post( "/admin/airflow/paused?" "dag_id=example_python_operator&is_paused=false") + self.assertIn("OK", response.data.decode('utf-8')) response = self.app.get("/admin/xcom", follow_redirects=True) self.assertIn("Xcoms", response.data.decode('utf-8'))
Vulnerability mechanics
Generated on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
9- github.com/advisories/GHSA-68wv-rjrm-576pghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2017-17835ghsaADVISORY
- github.com/apache/airflow/commit/673026c740411cc6447aede8c6a816460fe03a59ghsaWEB
- github.com/apache/airflow/commit/6aca2c2d395952341ab1b201c59011920b5a5c77ghsaWEB
- github.com/apache/airflow/commit/c9dc9263986c1a55520ba44b6e5b0fcbd6c48712ghsaWEB
- github.com/apache/airflow/commit/dca5e7d116b5c8b103df13f89f061757c13c41aeghsaWEB
- github.com/pypa/advisory-database/tree/main/vulns/apache-airflow/PYSEC-2019-148.yamlghsaWEB
- lists.apache.org/thread.html/ade4d54ebf614f68dc81a08891755e60ea58ba88e0209233eeea5f57%40%3Cdev.airflow.apache.org%3Emitrex_refsource_MISC
- lists.apache.org/thread.html/ade4d54ebf614f68dc81a08891755e60ea58ba88e0209233eeea5f57@%3Cdev.airflow.apache.org%3EghsaWEB
News mentions
0No linked articles in our index yet.