VYPR
Moderate severityNVD Advisory· Published Jul 16, 2024· Updated Feb 13, 2025

Apache Superset: Improper SQL authorisation, parse not checking for specific engine functions

CVE-2024-39887

Description

An SQL Injection vulnerability in Apache Superset exists due to improper neutralization of special elements used in SQL commands. Specifically, certain engine-specific functions are not checked, which allows attackers to bypass Apache Superset's SQL authorization. To mitigate this, a new configuration key named DISALLOWED_SQL_FUNCTIONS has been introduced. This key disallows the use of the following PostgreSQL functions: version, query_to_xml, inet_server_addr, and inet_client_addr. Additional functions can be added to this list for increased protection.

This issue affects Apache Superset: before 4.0.2.

Users are recommended to upgrade to version 4.0.2, which fixes the issue.

AI Insight

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

Apache Superset before 4.0.2 allows SQL injection via unblocked engine-specific functions, bypassing SQL authorization to execute arbitrary or info-leaking queries.

Vulnerability

Overview

CVE-2024-39887 is an SQL injection vulnerability in Apache Superset that stems from improper neutralization of special elements used in SQL commands. The core issue is that certain engine-specific functions were not being checked against the existing SQL authorization mechanism, allowing attackers to bypass intended restrictions [1][2]. The advisory notes that functions like version, query_to_xml, inet_server_addr, and inet_client_addr in PostgreSQL, as well as url in ClickHouse and version in MySQL, were exploitable without proper filtering [1][4].

Exploitation

Path

An authenticated attacker with access to Superset's SQL Lab or chart query interface can inject these unblocked functions into SQL queries. Because the authorization layer did not validate or block these engine-specific calls, the attacker could execute queries that the administrator intended to prevent. The attack does not require special privileges beyond the ability to run arbitrary SQL in Superset [2][4].

Impact

Successful exploitation allows an attacker to disclose sensitive database internals (e.g., database version, server network addresses, or XML data) by calling functions like version() or inet_server_addr(). This can lead to information disclosure that aids further targeted attacks. In databases like ClickHouse, the url() function could potentially be abused to exfiltrate data via HTTP requests [1][4].

Mitigation

Apache Superset released version 4.0.2, which introduces the DISALLOWED_SQL_FUNCTIONS configuration key. This key now blocks the known dangerous functions per engine (PostgreSQL, ClickHouse, MySQL) and allows administrators to add additional functions for enhanced protection [1][2]. All users running versions before 4.0.2 are strongly recommended to upgrade immediately.

AI Insight generated on May 20, 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-supersetPyPI
< 4.0.24.0.2

Affected products

3

Patches

2
56f0103b5771

fix: adds the ability to disallow SQL functions per engine (#28639)

https://github.com/apache/supersetDaniel Vaz GasparMay 29, 2024via ghsa
10 files changed · +130 17
  • superset/config.py+9 0 modified
    @@ -1203,6 +1203,15 @@ def CSV_TO_HIVE_UPLOAD_DIRECTORY_FUNC(  # pylint: disable=invalid-name
     # as such `create_engine(url, **params)`
     DB_CONNECTION_MUTATOR = None
     
    +# A set of disallowed SQL functions per engine. This is used to restrict the use of
    +# unsafe SQL functions in SQL Lab and Charts. The keys of the dictionary are the engine
    +# names, and the values are sets of disallowed functions.
    +DISALLOWED_SQL_FUNCTIONS: dict[str, set[str]] = {
    +    "postgresql": {"version", "query_to_xml", "inet_server_addr", "inet_client_addr"},
    +    "clickhouse": {"url"},
    +    "mysql": {"version"},
    +}
    +
     
     # A function that intercepts the SQL to be executed and can alter it.
     # The use case is can be around adding some sort of comment header
    
  • superset/db_engine_specs/base.py+6 0 modified
    @@ -59,6 +59,7 @@
     from superset.constants import TimeGrain as TimeGrainConstants
     from superset.databases.utils import make_url_safe
     from superset.errors import ErrorLevel, SupersetError, SupersetErrorType
    +from superset.exceptions import DisallowedSQLFunction
     from superset.sql_parse import ParsedQuery, Table
     from superset.superset_typing import ResultSetColumnType, SQLAColumnType
     from superset.utils import core as utils
    @@ -1584,6 +1585,11 @@ def execute(  # pylint: disable=unused-argument
             """
             if not cls.allows_sql_comments:
                 query = sql_parse.strip_comments_from_sql(query, engine=cls.engine)
    +        disallowed_functions = current_app.config["DISALLOWED_SQL_FUNCTIONS"].get(
    +            cls.engine, set()
    +        )
    +        if sql_parse.check_sql_functions_exist(query, disallowed_functions, cls.engine):
    +            raise DisallowedSQLFunction(disallowed_functions)
     
             if cls.arraysize:
                 cursor.arraysize = cls.arraysize
    
  • superset/db_engine_specs/trino.py+12 4 modified
    @@ -23,7 +23,7 @@
     from typing import Any, TYPE_CHECKING
     
     import simplejson as json
    -from flask import current_app
    +from flask import current_app, Flask
     from sqlalchemy.engine.reflection import Inspector
     from sqlalchemy.engine.url import URL
     from sqlalchemy.exc import NoSuchTableError
    @@ -206,19 +206,27 @@ def execute_with_cursor(cls, cursor: Cursor, sql: str, query: Query) -> None:
             execute_result: dict[str, Any] = {}
             execute_event = threading.Event()
     
    -        def _execute(results: dict[str, Any], event: threading.Event) -> None:
    +        def _execute(
    +            results: dict[str, Any], event: threading.Event, app: Flask
    +        ) -> None:
                 logger.debug("Query %d: Running query: %s", query_id, sql)
     
                 try:
    -                cls.execute(cursor, sql)
    +                with app.app_context():
    +                    cls.execute(cursor, sql)
                 except Exception as ex:  # pylint: disable=broad-except
                     results["error"] = ex
                 finally:
                     event.set()
     
             execute_thread = threading.Thread(
                 target=_execute,
    -            args=(execute_result, execute_event),
    +            args=(
    +                execute_result,
    +                execute_event,
    +                # pylint: disable=protected-access
    +                current_app._get_current_object(),
    +            ),
             )
             execute_thread.start()
     
    
  • superset/exceptions.py+15 0 modified
    @@ -295,3 +295,18 @@ def __init__(self, exc: ValidationError, payload: dict[str, Any]):
                 extra={"messages": exc.messages, "payload": payload},
             )
             super().__init__(error)
    +
    +
    +class DisallowedSQLFunction(SupersetErrorException):
    +    """
    +    Disallowed function found on SQL statement
    +    """
    +
    +    def __init__(self, functions: set[str]):
    +        super().__init__(
    +            SupersetError(
    +                message=f"SQL statement contains disallowed function(s): {functions}",
    +                error_type=SupersetErrorType.SYNTAX_ERROR,
    +                level=ErrorLevel.ERROR,
    +            )
    +        )
    
  • superset-frontend/cypress-base/cypress.config.ts+1 1 modified
    @@ -29,7 +29,7 @@ export default defineConfig({
       videoUploadOnPasses: false,
       viewportWidth: 1280,
       viewportHeight: 1024,
    -  projectId: 'ukwxzo',
    +  projectId: 'ud5x2f',
       retries: {
         runMode: 2,
         openMode: 0,
    
  • superset-frontend/src/dashboard/components/Header/HeaderActionsDropdown/index.jsx+1 1 modified
    @@ -16,7 +16,7 @@
      * specific language governing permissions and limitations
      * under the License.
      */
    -import React from 'react';
    +import React, { PureComponent } from 'react';
     import PropTypes from 'prop-types';
     import { isEmpty } from 'lodash';
     import { connect } from 'react-redux';
    
  • superset/sql_parse.py+42 0 modified
    @@ -36,6 +36,7 @@
     from sqlparse import keywords
     from sqlparse.lexer import Lexer
     from sqlparse.sql import (
    +    Function,
         Identifier,
         IdentifierList,
         Parenthesis,
    @@ -219,6 +220,19 @@ def get_cte_remainder_query(sql: str) -> tuple[str | None, str]:
         return cte, remainder
     
     
    +def check_sql_functions_exist(
    +    sql: str, function_list: set[str], engine: str | None = None
    +) -> bool:
    +    """
    +    Check if the SQL statement contains any of the specified functions.
    +
    +    :param sql: The SQL statement
    +    :param function_list: The list of functions to search for
    +    :param engine: The engine to use for parsing the SQL statement
    +    """
    +    return ParsedQuery(sql, engine=engine).check_functions_exist(function_list)
    +
    +
     def strip_comments_from_sql(statement: str, engine: str | None = None) -> str:
         """
         Strips comments from a SQL statement, does a simple test first
    @@ -288,6 +302,34 @@ def tables(self) -> set[Table]:
                 self._tables = self._extract_tables_from_sql()
             return self._tables
     
    +    def _check_functions_exist_in_token(
    +        self, token: Token, functions: set[str]
    +    ) -> bool:
    +        if (
    +            isinstance(token, Function)
    +            and token.get_name() is not None
    +            and token.get_name().lower() in functions
    +        ):
    +            return True
    +        if hasattr(token, "tokens"):
    +            for inner_token in token.tokens:
    +                if self._check_functions_exist_in_token(inner_token, functions):
    +                    return True
    +        return False
    +
    +    def check_functions_exist(self, functions: set[str]) -> bool:
    +        """
    +        Check if the SQL statement contains any of the specified functions.
    +
    +        :param functions: A set of functions to search for
    +        :return: True if the statement contains any of the specified functions
    +        """
    +        for statement in self._parsed:
    +            for token in statement.tokens:
    +                if self._check_functions_exist_in_token(token, functions):
    +                    return True
    +        return False
    +
         def _extract_tables_from_sql(self) -> set[Table]:
             """
             Extract all table references in a query.
    
  • tests/unit_tests/db_engine_specs/test_trino.py+14 10 modified
    @@ -396,7 +396,7 @@ def test_handle_cursor_early_cancel(
             assert cancel_query_mock.call_args is None
     
     
    -def test_execute_with_cursor_in_parallel(mocker: MockerFixture):
    +def test_execute_with_cursor_in_parallel(app, mocker: MockerFixture):
         """Test that `execute_with_cursor` fetches query ID from the cursor"""
         from superset.db_engine_specs.trino import TrinoEngineSpec
     
    @@ -411,16 +411,20 @@ def _mock_execute(*args, **kwargs):
             mock_cursor.query_id = query_id
     
         mock_cursor.execute.side_effect = _mock_execute
    +    with patch.dict(
    +        "superset.config.DISALLOWED_SQL_FUNCTIONS",
    +        {},
    +        clear=True,
    +    ):
    +        TrinoEngineSpec.execute_with_cursor(
    +            cursor=mock_cursor,
    +            sql="SELECT 1 FROM foo",
    +            query=mock_query,
    +        )
     
    -    TrinoEngineSpec.execute_with_cursor(
    -        cursor=mock_cursor,
    -        sql="SELECT 1 FROM foo",
    -        query=mock_query,
    -    )
    -
    -    mock_query.set_extra_json_key.assert_called_once_with(
    -        key=QUERY_CANCEL_KEY, value=query_id
    -    )
    +        mock_query.set_extra_json_key.assert_called_once_with(
    +            key=QUERY_CANCEL_KEY, value=query_id
    +        )
     
     
     def test_get_columns(mocker: MockerFixture):
    
  • tests/unit_tests/sql_parse_tests.py+26 0 modified
    @@ -32,6 +32,7 @@
     )
     from superset.sql_parse import (
         add_table_name,
    +    check_sql_functions_exist,
         extract_table_references,
         extract_tables_from_jinja_sql,
         get_rls_for_table,
    @@ -1292,6 +1293,31 @@ def test_strip_comments_from_sql() -> None:
         )
     
     
    +def test_check_sql_functions_exist() -> None:
    +    """
    +    Test that comments are stripped out correctly.
    +    """
    +    assert not (
    +        check_sql_functions_exist("select a, b from version", {"version"}, "postgresql")
    +    )
    +
    +    assert check_sql_functions_exist("select version()", {"version"}, "postgresql")
    +
    +    assert check_sql_functions_exist(
    +        "select version from version()", {"version"}, "postgresql"
    +    )
    +
    +    assert check_sql_functions_exist(
    +        "select 1, a.version from (select version from version()) as a",
    +        {"version"},
    +        "postgresql",
    +    )
    +
    +    assert check_sql_functions_exist(
    +        "select 1, a.version from (select version()) as a", {"version"}, "postgresql"
    +    )
    +
    +
     def test_sanitize_clause_valid():
         # regular clauses
         assert sanitize_clause("col = 1") == "col = 1"
    
  • tests/unit_tests/utils/csv_tests.py+4 1 modified
    @@ -15,6 +15,8 @@
     # specific language governing permissions and limitations
     # under the License.
     
    +import sys
    +
     import pandas as pd
     import pyarrow as pa
     import pytest
    @@ -57,6 +59,7 @@ def test_escape_value():
         assert result == "' =10+2"
     
     
    +@pytest.mark.skipif(sys.version_info < (3, 10), reason="requires Python 3.10 or later")
     def test_df_to_escaped_csv():
         df = pd.DataFrame(
             data={
    @@ -87,7 +90,7 @@ def test_df_to_escaped_csv():
             ["col_a"],
             ["'=func()"],
             ["-10"],
    -        ["\"'=cmd\\|' /C calc'!A0\""],
    +        [r"'=cmd\\|' /C calc'!A0"],
             ['"\'""""=b"'],
             ["' =a"],
             ["\x00"],
    

Vulnerability mechanics

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

References

5

News mentions

0

No linked articles in our index yet.