VYPR
Moderate severityNVD Advisory· Published Dec 11, 2020· Updated Feb 13, 2025

CVE-2020-17515

CVE-2020-17515

Description

The "origin" parameter passed to some of the endpoints like '/trigger' was vulnerable to XSS exploit. This issue affects Apache Airflow versions prior to 1.10.13. This is same as CVE-2020-13944 but the implemented fix in Airflow 1.10.13 did not fix the issue completely.

AI Insight

LLM-synthesized narrative grounded in this CVE's description and references.

Apache Airflow prior to 1.10.13 had an incomplete fix for CVE-2020-13944, leaving the 'origin' parameter in endpoints like /trigger vulnerable to XSS.

The vulnerability in Apache Airflow stems from an incomplete fix for CVE-2020-13944. The 'origin' parameter, which is passed to endpoints such as '/trigger', was not properly sanitized, leaving it susceptible to Cross-Site Scripting (XSS) attacks [1][3]. This issue affects all Apache Airflow versions prior to 1.10.13 [3]. Despite the initial fix, the implemented sanitization was insufficient and could be bypassed, allowing malicious script injection [1].

An attacker can exploit this vulnerability by crafting a malicious URL containing the 'origin' parameter with embedded JavaScript. When a user clicks on the crafted link or the malicious content is rendered by the Airflow web interface, the injected script executes in the context of the user's browser session [1][3]. No authentication is required beyond the user's existing session; the attacker only needs to trick a user into visiting the crafted link.

Successful exploitation allows an attacker to perform actions on behalf of the victim, such as accessing sensitive data, modifying workflows, or escalating privileges within the Airflow environment [1][3]. Since Airflow is often used to orchestrate data pipelines and manage critical infrastructure, an XSS attack could lead to significant data breaches or operational disruptions.

The issue was addressed in Apache Airflow 1.10.13, but it was later discovered that the fix was incomplete [1][3]. A more robust sanitization was implemented in subsequent releases, including version 2.0.2, which includes the commit "Webserver: Sanitize string passed to origin param" [4]. Users are advised to upgrade to a patched version to mitigate the risk [3].

AI Insight generated on May 21, 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.

PackageAffected versionsPatched versions
apache-airflowPyPI
< 1.10.15rc11.10.15rc1
apache-airflowPyPI
>= 2.0.0b1, < 2.0.2rc12.0.2rc1

Affected products

3

Patches

5
c6369beed53d

Webserver: Sanitize string passed to origin param (#14738)

https://github.com/apache/airflowKaxil NaikMar 12, 2021via ghsa
4 files changed · +53 13
  • airflow/www_rbac/views.py+12 1 modified
    @@ -100,8 +100,19 @@ def get_safe_url(url):
     
             parsed = urlparse(url)
     
    +        # If the url is relative & it contains semicolon, redirect it to homepage to avoid
    +        # potential XSS. (Similar to https://github.com/python/cpython/pull/24297/files (bpo-42967))
    +        if parsed.netloc == '' and parsed.scheme == '' and ';' in unquote(url):
    +            return url_for('Airflow.index')
    +
             query = parse_qsl(parsed.query, keep_blank_values=True)
    -        url = parsed._replace(query=urlencode(query)).geturl()
    +
    +        # Remove all the query elements containing semicolon
    +        # As part of https://github.com/python/cpython/pull/24297/files (bpo-42967)
    +        # semicolon was already removed as a separator for query arguments by default
    +        sanitized_query = [query_arg for query_arg in query if ';' not in query_arg[1]]
    +        url = parsed._replace(query=urlencode(sanitized_query)).geturl()
    +
             if parsed.scheme in valid_schemes and parsed.netloc in valid_netlocs:
                 return url
         except Exception as e:  # pylint: disable=broad-except
    
  • airflow/www/views.py+11 1 modified
    @@ -340,8 +340,18 @@ def get_safe_url(url):
     
             parsed = urlparse(url)
     
    +        # If the url is relative & it contains semicolon, redirect it to homepage to avoid
    +        # potential XSS. (Similar to https://github.com/python/cpython/pull/24297/files (bpo-42967))
    +        if parsed.netloc == '' and parsed.scheme == '' and ';' in unquote(url):
    +            return "/admin/"
    +
             query = parse_qsl(parsed.query, keep_blank_values=True)
    -        url = parsed._replace(query=urlencode(query)).geturl()
    +
    +        # Remove all the query elements containing semicolon
    +        # As part of https://github.com/python/cpython/pull/24297/files (bpo-42967)
    +        # semicolon was already removed as a separator for query arguments by default
    +        sanitized_query = [query_arg for query_arg in query if ';' not in query_arg[1]]
    +        url = parsed._replace(query=urlencode(sanitized_query)).geturl()
             if parsed.scheme in valid_schemes and parsed.netloc in valid_netlocs:
                 return url
         except Exception as e:  # pylint: disable=broad-except
    
  • tests/www_rbac/test_views.py+25 11 modified
    @@ -41,7 +41,7 @@
     from werkzeug.wrappers import BaseResponse
     
     from airflow import models, settings, version
    -from airflow.configuration import conf
    +from airflow.configuration import conf, WEBSERVER_CONFIG, _read_default_config_file
     from airflow.config_templates.airflow_local_settings import DEFAULT_LOGGING_CONFIG
     from airflow.executors.celery_executor import CeleryExecutor
     from airflow.jobs import BaseJob
    @@ -66,15 +66,26 @@
     class TestBase(unittest.TestCase):
         @classmethod
         def setUpClass(cls):
    +        cls.orig_rbac_conf = conf.get('webserver', 'rbac')
    +        conf.set('webserver', 'rbac', 'True')
    +        cls._create_default_webserver_config()
             cls.app, cls.appbuilder = application.create_app(session=Session, testing=True)
             cls.app.config['WTF_CSRF_ENABLED'] = False
             cls.app.jinja_env.undefined = jinja2.StrictUndefined
             settings.configure_orm()
             cls.session = Session
     
    +    @staticmethod
    +    def _create_default_webserver_config():
    +        if not os.path.isfile(WEBSERVER_CONFIG):
    +            DEFAULT_WEBSERVER_CONFIG, _ = _read_default_config_file('default_webserver_config.py')
    +            with open(WEBSERVER_CONFIG, 'w') as file:
    +                file.write(DEFAULT_WEBSERVER_CONFIG)
    +
         @classmethod
         def tearDownClass(cls):
             clear_db_runs()
    +        conf.set('webserver', 'rbac', cls.orig_rbac_conf)
     
         def setUp(self):
             self.client = self.app.test_client()
    @@ -2244,16 +2255,19 @@ def test_trigger_serialized_dag(self, mock_os_isfile, mock_dagrun):
             self.check_content_in_response(
                 'Triggered example_bash_operator, it should start any moment now.', response)
     
    -    @parameterized.expand([
    -        ("javascript:alert(1)", "/home"),
    -        ("http://google.com", "/home"),
    -        (
    -            "%2Ftree%3Fdag_id%3Dexample_bash_operator';alert(33)//",
    -            "/tree?dag_id=example_bash_operator%27&amp;alert%2833%29%2F%2F=",
    -        ),
    -        ("%2Ftree%3Fdag_id%3Dexample_bash_operator", "/tree?dag_id=example_bash_operator"),
    -        ("%2Fgraph%3Fdag_id%3Dexample_bash_operator", "/graph?dag_id=example_bash_operator"),
    -    ])
    +    @parameterized.expand(
    +        [
    +            ("javascript:alert(1)", "/home"),
    +            ("http://google.com", "/home"),
    +            ("36539'%3balert(1)%2f%2f166", "/home"),
    +            (
    +                "%2Ftree%3Fdag_id%3Dexample_bash_operator';alert(33)//",
    +                "/home",
    +            ),
    +            ("%2Ftree%3Fdag_id%3Dexample_bash_operator", "/tree?dag_id=example_bash_operator"),
    +            ("%2Fgraph%3Fdag_id%3Dexample_bash_operator", "/graph?dag_id=example_bash_operator"),
    +        ]
    +    )
         def test_trigger_dag_form_origin_url(self, test_origin, expected_origin):
             test_dag_id = "example_bash_operator"
     
    
  • tests/www/test_views.py+5 0 modified
    @@ -1119,6 +1119,11 @@ def test_trigger_serialized_dag(self, mock_os_isfile, mock_dagrun):
         @parameterized.expand([
             ("javascript:alert(1)", "/admin/"),
             ("http://google.com", "/admin/"),
    +        ("36539'%3balert(1)%2f%2f166", "/admin/"),
    +        (
    +            "%2Fadmin%2Fairflow%2Ftree%3Fdag_id%3Dexample_bash_operator';alert(33)//",
    +            "/admin/",
    +        ),
             (
                 "%2Fadmin%2Fairflow%2Ftree%3Fdag_id%3Dexample_bash_operator"
                 "&dag_id=example_bash_operator';alert(33)//",
    
ab8c55878e3e

Webserver: Sanitize string passed to origin param (#14738)

https://github.com/apache/airflowKaxil NaikMar 12, 2021via ghsa
2 files changed · +20 19
  • airflow/www/views.py+11 1 modified
    @@ -129,8 +129,18 @@ def get_safe_url(url):
     
         parsed = urlparse(url)
     
    +    # If the url is relative & it contains semicolon, redirect it to homepage to avoid
    +    # potential XSS. (Similar to https://github.com/python/cpython/pull/24297/files (bpo-42967))
    +    if parsed.netloc == '' and parsed.scheme == '' and ';' in unquote(url):
    +        return url_for('Airflow.index')
    +
         query = parse_qsl(parsed.query, keep_blank_values=True)
    -    url = parsed._replace(query=urlencode(query)).geturl()
    +
    +    # Remove all the query elements containing semicolon
    +    # As part of https://github.com/python/cpython/pull/24297/files (bpo-42967)
    +    # semicolon was already removed as a separator for query arguments by default
    +    sanitized_query = [query_arg for query_arg in query if ';' not in query_arg[1]]
    +    url = parsed._replace(query=urlencode(sanitized_query)).geturl()
     
         if parsed.scheme in valid_schemes and parsed.netloc in valid_netlocs:
             return url
    
  • tests/www/test_views.py+9 18 modified
    @@ -32,7 +32,7 @@
     from typing import Any, Dict, Generator, List, NamedTuple
     from unittest import mock
     from unittest.mock import PropertyMock
    -from urllib.parse import parse_qsl, quote_plus
    +from urllib.parse import quote_plus
     
     import jinja2
     import pytest
    @@ -2755,9 +2755,10 @@ def test_trigger_dag_form(self):
             [
                 ("javascript:alert(1)", "/home"),
                 ("http://google.com", "/home"),
    +            ("36539'%3balert(1)%2f%2f166", "/home"),
                 (
                     "%2Ftree%3Fdag_id%3Dexample_bash_operator';alert(33)//",
    -                "/tree?dag_id=example_bash_operator%27%3Balert%2833%29%2F%2F",
    +                "/home",
                 ),
                 ("%2Ftree%3Fdag_id%3Dexample_bash_operator", "/tree?dag_id=example_bash_operator"),
                 ("%2Fgraph%3Fdag_id%3Dexample_bash_operator", "/graph?dag_id=example_bash_operator"),
    @@ -2766,13 +2767,6 @@ def test_trigger_dag_form(self):
         def test_trigger_dag_form_origin_url(self, test_origin, expected_origin):
             test_dag_id = "example_bash_operator"
     
    -        # https://github.com/python/cpython/pull/24297/files
    -        # Check if tests are running with a Python version containing the above fix
    -        # where ";" is removed as a separator
    -        if parse_qsl(";a=b") != [(';a', 'b')] and ";" in test_origin:
    -            expected_origin = expected_origin.replace("%3B", "&")
    -            expected_origin += "="
    -
             resp = self.client.get(f'trigger?dag_id={test_dag_id}&origin={test_origin}')
             self.check_content_in_response(
                 '<button type="button" class="btn" onclick="location.href = \'{}\'; return false">'.format(
    @@ -3302,10 +3296,14 @@ class TestHelperFunctions(TestBase):
             [
                 ("", "/home"),
                 ("http://google.com", "/home"),
    +            ("36539'%3balert(1)%2f%2f166", "/home"),
    +            (
    +                "http://localhost:8080/trigger?dag_id=test&origin=36539%27%3balert(1)%2f%2f166&abc=2",
    +                "http://localhost:8080/trigger?dag_id=test&abc=2",
    +            ),
                 (
                     "http://localhost:8080/trigger?dag_id=test_dag&origin=%2Ftree%3Fdag_id%test_dag';alert(33)//",
    -                "http://localhost:8080/trigger?dag_id=test_dag&origin=%2Ftree%3F"
    -                "dag_id%25test_dag%27%3Balert%2833%29%2F%2F",
    +                "http://localhost:8080/trigger?dag_id=test_dag",
                 ),
                 (
                     "http://localhost:8080/trigger?dag_id=test_dag&origin=%2Ftree%3Fdag_id%test_dag",
    @@ -3315,13 +3313,6 @@ class TestHelperFunctions(TestBase):
         )
         @mock.patch("airflow.www.views.url_for")
         def test_get_safe_url(self, test_url, expected_url, mock_url_for):
    -        # https://github.com/python/cpython/pull/24297/files
    -        # Check if tests are running with a Python version containing the above fix
    -        # where ";" is removed as a separator
    -        if parse_qsl(";a=b") != [(';a', 'b')] and ";" in test_url:
    -            expected_url = expected_url.replace("%3B", "&")
    -            expected_url += "="
    -
             mock_url_for.return_value = "/home"
             with self.app.test_request_context(base_url="http://localhost:8080"):
                 assert get_safe_url(test_url) == expected_url
    
409c249121bd

Webserver: Sanitize string passed to origin param (#14738)

https://github.com/apache/airflowKaxil NaikMar 12, 2021via ghsa
2 files changed · +20 19
  • airflow/www/views.py+11 1 modified
    @@ -129,8 +129,18 @@ def get_safe_url(url):
     
         parsed = urlparse(url)
     
    +    # If the url is relative & it contains semicolon, redirect it to homepage to avoid
    +    # potential XSS. (Similar to https://github.com/python/cpython/pull/24297/files (bpo-42967))
    +    if parsed.netloc == '' and parsed.scheme == '' and ';' in unquote(url):
    +        return url_for('Airflow.index')
    +
         query = parse_qsl(parsed.query, keep_blank_values=True)
    -    url = parsed._replace(query=urlencode(query)).geturl()
    +
    +    # Remove all the query elements containing semicolon
    +    # As part of https://github.com/python/cpython/pull/24297/files (bpo-42967)
    +    # semicolon was already removed as a separator for query arguments by default
    +    sanitized_query = [query_arg for query_arg in query if ';' not in query_arg[1]]
    +    url = parsed._replace(query=urlencode(sanitized_query)).geturl()
     
         if parsed.scheme in valid_schemes and parsed.netloc in valid_netlocs:
             return url
    
  • tests/www/test_views.py+9 18 modified
    @@ -32,7 +32,7 @@
     from typing import Any, Dict, Generator, List, NamedTuple
     from unittest import mock
     from unittest.mock import PropertyMock
    -from urllib.parse import parse_qsl, quote_plus
    +from urllib.parse import quote_plus
     
     import jinja2
     import pytest
    @@ -2776,9 +2776,10 @@ def test_trigger_dag_form(self):
             [
                 ("javascript:alert(1)", "/home"),
                 ("http://google.com", "/home"),
    +            ("36539'%3balert(1)%2f%2f166", "/home"),
                 (
                     "%2Ftree%3Fdag_id%3Dexample_bash_operator';alert(33)//",
    -                "/tree?dag_id=example_bash_operator%27%3Balert%2833%29%2F%2F",
    +                "/home",
                 ),
                 ("%2Ftree%3Fdag_id%3Dexample_bash_operator", "/tree?dag_id=example_bash_operator"),
                 ("%2Fgraph%3Fdag_id%3Dexample_bash_operator", "/graph?dag_id=example_bash_operator"),
    @@ -2787,13 +2788,6 @@ def test_trigger_dag_form(self):
         def test_trigger_dag_form_origin_url(self, test_origin, expected_origin):
             test_dag_id = "example_bash_operator"
     
    -        # https://github.com/python/cpython/pull/24297/files
    -        # Check if tests are running with a Python version containing the above fix
    -        # where ";" is removed as a separator
    -        if parse_qsl(";a=b") != [(';a', 'b')] and ";" in test_origin:
    -            expected_origin = expected_origin.replace("%3B", "&")
    -            expected_origin += "="
    -
             resp = self.client.get(f'trigger?dag_id={test_dag_id}&origin={test_origin}')
             self.check_content_in_response(
                 '<button type="button" class="btn" onclick="location.href = \'{}\'; return false">'.format(
    @@ -3325,10 +3319,14 @@ class TestHelperFunctions(TestBase):
             [
                 ("", "/home"),
                 ("http://google.com", "/home"),
    +            ("36539'%3balert(1)%2f%2f166", "/home"),
    +            (
    +                "http://localhost:8080/trigger?dag_id=test&origin=36539%27%3balert(1)%2f%2f166&abc=2",
    +                "http://localhost:8080/trigger?dag_id=test&abc=2",
    +            ),
                 (
                     "http://localhost:8080/trigger?dag_id=test_dag&origin=%2Ftree%3Fdag_id%test_dag';alert(33)//",
    -                "http://localhost:8080/trigger?dag_id=test_dag&origin=%2Ftree%3F"
    -                "dag_id%25test_dag%27%3Balert%2833%29%2F%2F",
    +                "http://localhost:8080/trigger?dag_id=test_dag",
                 ),
                 (
                     "http://localhost:8080/trigger?dag_id=test_dag&origin=%2Ftree%3Fdag_id%test_dag",
    @@ -3338,13 +3336,6 @@ class TestHelperFunctions(TestBase):
         )
         @mock.patch("airflow.www.views.url_for")
         def test_get_safe_url(self, test_url, expected_url, mock_url_for):
    -        # https://github.com/python/cpython/pull/24297/files
    -        # Check if tests are running with a Python version containing the above fix
    -        # where ";" is removed as a separator
    -        if parse_qsl(";a=b") != [(';a', 'b')] and ";" in test_url:
    -            expected_url = expected_url.replace("%3B", "&")
    -            expected_url += "="
    -
             mock_url_for.return_value = "/home"
             with self.app.test_request_context(base_url="http://localhost:8080"):
                 assert get_safe_url(test_url) == expected_url
    
7486153f451e

fixup! Webserver: Further Sanitize values passed to origin param

https://github.com/apache/airflowKaxil NaikNov 18, 2020via ghsa
2 files changed · +11 5
  • airflow/www/views.py+4 3 modified
    @@ -108,11 +108,12 @@ def get_safe_url(url):
         valid_schemes = ['http', 'https', '']
         valid_netlocs = [request.host, '']
     
    -    # Remove single quotes
    -    url = url.replace("'", "")
    +    if not url:
    +        return url_for('Airflow.index')
    +
         parsed = urlparse(url)
     
    -    query = parse_qsl(parsed.query)
    +    query = parse_qsl(parsed.query, keep_blank_values=True)
         url = parsed._replace(query=urlencode(query)).geturl()
     
         if parsed.scheme in valid_schemes and parsed.netloc in valid_netlocs:
    
  • tests/www/test_views.py+7 2 modified
    @@ -2772,7 +2772,10 @@ def test_trigger_dag_form(self):
             [
                 ("javascript:alert(1)", "/home"),
                 ("http://google.com", "/home"),
    -            ("%2Ftree%3Fdag_id%3Dexample_bash_operator%27;alert(33)//", "/tree?dag_id=example_bash_operator"),
    +            (
    +                "%2Ftree%3Fdag_id%3Dexample_bash_operator';alert(33)//",
    +                "/tree?dag_id=example_bash_operator%27&amp;alert%2833%29%2F%2F=",
    +            ),
                 ("%2Ftree%3Fdag_id%3Dexample_bash_operator", "/tree?dag_id=example_bash_operator"),
                 ("%2Fgraph%3Fdag_id%3Dexample_bash_operator", "/graph?dag_id=example_bash_operator"),
             ]
    @@ -3299,10 +3302,12 @@ def test_action_logging_post(self):
     class TestHelperFunctions(unittest.TestCase):
         @parameterized.expand(
             [
    +            ("", "/home"),
                 ("http://google.com", "/home"),
                 (
                     "http://localhost:8080/trigger?dag_id=test_dag&origin=%2Ftree%3Fdag_id%test_dag';alert(33)//",
    -                "http://localhost:8080/trigger?dag_id=test_dag&origin=%2Ftree%3Fdag_id%25test_dag",
    +                "http://localhost:8080/trigger?dag_id=test_dag&origin=%2Ftree%3F"
    +                "dag_id%25test_dag%27&alert%2833%29%2F%2F=",
                 ),
                 (
                     "http://localhost:8080/trigger?dag_id=test_dag&origin=%2Ftree%3Fdag_id%test_dag",
    
13336272e328

Webserver: Further Sanitize values passed to origin param

https://github.com/apache/airflowKaxil NaikNov 18, 2020via ghsa
2 files changed · +30 2
  • airflow/www/views.py+6 1 modified
    @@ -28,7 +28,7 @@
     from datetime import datetime, timedelta
     from json import JSONDecodeError
     from typing import Dict, List, Optional, Tuple
    -from urllib.parse import unquote, urlparse
    +from urllib.parse import parse_qsl, unquote, urlencode, urlparse
     
     import lazy_object_proxy
     import nvd3
    @@ -108,8 +108,13 @@ def get_safe_url(url):
         valid_schemes = ['http', 'https', '']
         valid_netlocs = [request.host, '']
     
    +    # Remove single quotes
    +    url = url.replace("'", "")
         parsed = urlparse(url)
     
    +    query = parse_qsl(parsed.query)
    +    url = parsed._replace(query=urlencode(query)).geturl()
    +
         if parsed.scheme in valid_schemes and parsed.netloc in valid_netlocs:
             return url
     
    
  • tests/www/test_views.py+24 1 modified
    @@ -62,7 +62,7 @@
     from airflow.utils.timezone import datetime
     from airflow.utils.types import DagRunType
     from airflow.www import app as application
    -from airflow.www.views import ConnectionModelView
    +from airflow.www.views import ConnectionModelView, get_safe_url
     from tests.test_utils import fab_utils
     from tests.test_utils.asserts import assert_queries_count
     from tests.test_utils.config import conf_vars
    @@ -2772,6 +2772,7 @@ def test_trigger_dag_form(self):
             [
                 ("javascript:alert(1)", "/home"),
                 ("http://google.com", "/home"),
    +            ("%2Ftree%3Fdag_id%3Dexample_bash_operator%27;alert(33)//", "/tree?dag_id=example_bash_operator"),
                 ("%2Ftree%3Fdag_id%3Dexample_bash_operator", "/tree?dag_id=example_bash_operator"),
                 ("%2Fgraph%3Fdag_id%3Dexample_bash_operator", "/graph?dag_id=example_bash_operator"),
             ]
    @@ -3293,3 +3294,25 @@ def test_action_logging_post(self):
             self.check_last_log(
                 "example_bash_operator", event="clear", execution_date=self.EXAMPLE_DAG_DEFAULT_DATE
             )
    +
    +
    +class TestHelperFunctions(unittest.TestCase):
    +    @parameterized.expand(
    +        [
    +            ("http://google.com", "/home"),
    +            (
    +                "http://localhost:8080/trigger?dag_id=test_dag&origin=%2Ftree%3Fdag_id%test_dag';alert(33)//",
    +                "http://localhost:8080/trigger?dag_id=test_dag&origin=%2Ftree%3Fdag_id%25test_dag",
    +            ),
    +            (
    +                "http://localhost:8080/trigger?dag_id=test_dag&origin=%2Ftree%3Fdag_id%test_dag",
    +                "http://localhost:8080/trigger?dag_id=test_dag&origin=%2Ftree%3Fdag_id%25test_dag",
    +            ),
    +        ]
    +    )
    +    @mock.patch("airflow.www.views.url_for")
    +    @mock.patch("airflow.www.views.request")
    +    def test_get_safe_url(self, test_url, expected_url, mock_req, mock_url_for):
    +        mock_req.host = 'localhost:8080'
    +        mock_url_for.return_value = "/home"
    +        self.assertEqual(get_safe_url(test_url), expected_url)
    

Vulnerability mechanics

Generated on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

26

News mentions

0

No linked articles in our index yet.