VYPR
High severityNVD Advisory· Published Feb 21, 2026· Updated Feb 24, 2026

D-Tale affected by Remote Code Execution through the /save-column-filter endpoint

CVE-2026-27194

Description

D-Tale is a visualizer for pandas data structures. Versions prior to 3.20.0 are vulnerable to Remote Code Execution through the /save-column-filter endpoint. Users hosting D-Tale publicly can be vulnerable to remote code execution allowing attackers to run malicious code on the server. This issue has been fixed in version 3.20.0.

AI Insight

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

D-Tale versions before 3.20.0 allow remote code execution via the /save-column-filter endpoint due to insufficient input validation.

Vulnerability

Overview

D-Tale, a popular visualizer for pandas data structures, is vulnerable to remote code execution (RCE) in versions prior to 3.20.0. The flaw resides in the /save-column-filter endpoint, which passes user-supplied filter values directly to DataFrame.query() without adequate sanitization [1][2]. This allows attackers to inject arbitrary Python code that is executed on the server.

Exploitation

An attacker can exploit this vulnerability by sending a crafted HTTP request to the /save-column-filter endpoint with malicious filter strings. No authentication is required if the D-Tale instance is publicly accessible. The injected code can include dangerous functions such as exec(), eval(), __import__, or access to modules like os and subprocess [3]. The fix introduced in version 3.20.0 adds a regex-based validation that blocks these patterns [3].

Impact

Successful exploitation grants the attacker full remote code execution on the server. This can lead to data exfiltration, system compromise, or further lateral movement within the network. The vulnerability is particularly critical for users who expose D-Tale to the internet without proper access controls.

Mitigation

Users should upgrade to D-Tale version 3.20.0 immediately. According to the official security advisory, there are no workarounds for versions below 3.20.0 [4]. The patch is available in the commit that adds input validation to the column filter endpoint [3].

AI Insight generated on May 19, 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
dtalePyPI
< 3.20.03.20.0

Affected products

2
  • Man/D Talellm-fuzzy
    Range: <3.20.0
  • man-group/dtalev5
    Range: < 3.20.0

Patches

1
431c6148d3c7

Add input validation to guard against code injection via DataFrame.query()

https://github.com/man-group/dtaleAndrew Schonfeld (Boston)Feb 18, 2026via ghsa
3 files changed · +357 4
  • dtale/column_filters.py+149 0 modified
    @@ -1,11 +1,121 @@
     import json
    +import re
    +
     import numpy as np
     import pandas as pd
    +from six import string_types
     
     import dtale.global_state as global_state
     from dtale.utils import classify_type, find_dtype, format_data, make_list
     from dtale.query import build_col_key
     
    +# Patterns that could enable code execution via pandas.DataFrame.query()
    +_DANGEROUS_PATTERNS = re.compile(
    +    r"(__\w+__"  # dunder attributes (__import__, __class__, etc.)
    +    r"|(?<!\w)import\s*\("  # import() calls
    +    r"|(?<!\w)exec\s*\("  # exec() calls
    +    r"|(?<!\w)eval\s*\("  # eval() calls
    +    r"|(?<!\w)compile\s*\("  # compile() calls
    +    r"|(?<!\w)open\s*\("  # open() calls
    +    r"|(?<!\w)getattr\s*\("  # getattr() calls
    +    r"|(?<!\w)setattr\s*\("  # setattr() calls
    +    r"|(?<!\w)delattr\s*\("  # delattr() calls
    +    r"|(?<!\w)globals\s*\("  # globals() calls
    +    r"|(?<!\w)locals\s*\("  # locals() calls
    +    r"|(?<!\w)vars\s*\("  # vars() calls
    +    r"|(?<!\w)dir\s*\("  # dir() calls
    +    r"|(?<!\w)type\s*\("  # type() calls
    +    r"|(?<!\w)os\."  # os module access
    +    r"|(?<!\w)sys\."  # sys module access
    +    r"|(?<!\w)subprocess"  # subprocess module access
    +    r"|(?<!\w)shutil\."  # shutil module access
    +    r"|@\w+"  # @ references to local variables in query scope
    +    r")",
    +    re.IGNORECASE,
    +)
    +
    +
    +class ColumnFilterSecurity(object):
    +    """Validation helpers to guard against code injection via DataFrame.query()."""
    +
    +    @staticmethod
    +    def validate_string_value(val):
    +        """Validate a string value used in filter queries."""
    +        if not isinstance(val, string_types):
    +            val = str(val)
    +        if _DANGEROUS_PATTERNS.search(val):
    +            raise ValueError(
    +                "Filter value contains potentially unsafe content: {}".format(repr(val))
    +            )
    +        return val
    +
    +    @staticmethod
    +    def validate_numeric_value(val):
    +        """Validate that a value is actually numeric. Returns the original value if valid."""
    +        if val is None:
    +            return val
    +        if isinstance(val, (int, float, np.integer, np.floating)):
    +            return val
    +        # Try to parse string as a number (validation only, return original value)
    +        try:
    +            if isinstance(val, string_types):
    +                stripped = val.strip()
    +                if _DANGEROUS_PATTERNS.search(stripped):
    +                    raise ValueError(
    +                        "Numeric filter value contains potentially unsafe content: {}".format(
    +                            repr(val)
    +                        )
    +                    )
    +                float(stripped)  # validate it parses as a number
    +                return val  # return original to preserve existing query format
    +        except (ValueError, TypeError):
    +            pass
    +        raise ValueError("Expected numeric value, got: {}".format(repr(val)))
    +
    +    @staticmethod
    +    def validate_date_value(val):
    +        """Validate that a value is a safe date string."""
    +        if val is None:
    +            return val
    +        if not isinstance(val, string_types):
    +            val = str(val)
    +        # Date values should only contain digits, dashes, colons, T, Z, dots, spaces, +
    +        if not re.match(r"^[\d\-/:T Z.+]+$", val):
    +            raise ValueError(
    +                "Date filter value contains invalid characters: {}".format(repr(val))
    +            )
    +        if _DANGEROUS_PATTERNS.search(val):
    +            raise ValueError(
    +                "Date filter value contains potentially unsafe content: {}".format(
    +                    repr(val)
    +                )
    +            )
    +        return val
    +
    +    @staticmethod
    +    def validate_operand(operand, allowed):
    +        """Validate that the operand is in the allowed set."""
    +        if operand not in allowed:
    +            raise ValueError(
    +                "Invalid operand: {}. Allowed: {}".format(repr(operand), allowed)
    +            )
    +        return operand
    +
    +    @staticmethod
    +    def validate_outlier_query(query):
    +        """Validate an outlier filter query string."""
    +        if query is None:
    +            return query
    +        if not isinstance(query, string_types):
    +            raise ValueError("Outlier query must be a string")
    +        if _DANGEROUS_PATTERNS.search(query):
    +            raise ValueError(
    +                "Outlier filter query contains potentially unsafe content: {}".format(
    +                    repr(query)
    +                )
    +            )
    +        return query
    +
     
     class ArcticDBColumnFilter(object):
         def __init__(self, saved_filter):
    @@ -76,6 +186,7 @@ def __init__(self, column, classification, cfg):
         def build_filter(self):
             if self.cfg.get("query") is None:
                 return None
    +        ColumnFilterSecurity.validate_outlier_query(self.cfg.get("query"))
             return self.cfg
     
     
    @@ -144,6 +255,16 @@ def build_filter(self):
                 return super(StringFilter, self).handle_missing_or_populated(None)
     
             action = self.cfg.get("action", "equals")
    +        # Validate action
    +        if action not in (
    +            "equals",
    +            "startswith",
    +            "endswith",
    +            "contains",
    +            "regex",
    +            "length",
    +        ):
    +            raise ValueError("Invalid string filter action: {}".format(repr(action)))
             if action == "equals" and not len(self.cfg.get("value", [])):
                 return super(StringFilter, self).handle_missing_or_populated(None)
             elif action != "equals" and not self.cfg.get("raw"):
    @@ -153,6 +274,14 @@ def build_filter(self):
             case_sensitive = self.cfg.get("caseSensitive", False)
             operand = self.cfg.get("operand", "=")
             raw = self.cfg.get("raw")
    +
    +        # Validate inputs
    +        ColumnFilterSecurity.validate_operand(operand, {"=", "ne"})
    +        if raw is not None:
    +            ColumnFilterSecurity.validate_string_value(raw)
    +        for v in state:
    +            ColumnFilterSecurity.validate_string_value(v)
    +
             fltr = dict(
                 value=state,
                 operand=operand,
    @@ -199,12 +328,16 @@ def build_filter(self):
                 )
                 fltr["query"] = handle_ne(fltr["query"], operand)
             elif action == "length":
    +            # Validate length values are numeric
                 if "," in raw:
                     start, end = raw.split(",")
    +                ColumnFilterSecurity.validate_numeric_value(start.strip())
    +                ColumnFilterSecurity.validate_numeric_value(end.strip())
                     fltr["query"] = "{start} <= {col}.str.len() <= {end}".format(
                         col=build_col_key(self.column), start=start, end=end
                     )
                 else:
    +                ColumnFilterSecurity.validate_numeric_value(raw.strip())
                     fltr["query"] = "{}.str.len() == {}".format(
                         build_col_key(self.column), raw
                     )
    @@ -272,6 +405,12 @@ def build_filter(self):
                 self.cfg.get(p) for p in ["value", "operand", "min", "max"]
             )
     
    +        # Validate operand (allow None for missing/populated-only filters)
    +        if cfg_operand is not None:
    +            ColumnFilterSecurity.validate_operand(
    +                cfg_operand, {"=", "ne", "<", ">", "<=", ">=", "[]", "()"}
    +            )
    +
             base_fltr = dict(
                 operand=cfg_operand,
                 meta={
    @@ -284,6 +423,8 @@ def build_filter(self):
                 state = make_list(cfg_val or [])
                 if not len(state):
                     return super(NumericFilter, self).handle_missing_or_populated(None)
    +            # Validate all values are numeric
    +            state = [ColumnFilterSecurity.validate_numeric_value(v) for v in state]
                 fltr = dict(value=cfg_val, **base_fltr)
                 if len(state) == 1:
                     fltr["query"] = "{} {} {}".format(
    @@ -301,6 +442,7 @@ def build_filter(self):
             if cfg_operand in ["<", ">", "<=", ">="]:
                 if cfg_val is None:
                     return super(NumericFilter, self).handle_missing_or_populated(None)
    +            cfg_val = ColumnFilterSecurity.validate_numeric_value(cfg_val)
                 fltr = dict(
                     value=cfg_val,
                     query="{} {} {}".format(
    @@ -313,6 +455,7 @@ def build_filter(self):
                 fltr = dict(**base_fltr)
                 queries = []
                 if cfg_min is not None:
    +                cfg_min = ColumnFilterSecurity.validate_numeric_value(cfg_min)
                     fltr["min"] = cfg_min
                     queries.append(
                         "{} >{} {}".format(
    @@ -322,6 +465,7 @@ def build_filter(self):
                         )
                     )
                 if cfg_max is not None:
    +                cfg_max = ColumnFilterSecurity.validate_numeric_value(cfg_max)
                     fltr["max"] = cfg_max
                     queries.append(
                         "{} <{} {}".format(
    @@ -403,6 +547,11 @@ def build_filter(self):
                 return super(DateFilter, self).handle_missing_or_populated(None)
     
             start, end = (self.cfg.get(p) for p in ["start", "end"])
    +        # Validate date values
    +        if start:
    +            ColumnFilterSecurity.validate_date_value(start)
    +        if end:
    +            ColumnFilterSecurity.validate_date_value(end)
             fltr = dict(
                 start=start,
                 end=end,
    
  • dtale/query.py+43 0 modified
    @@ -1,10 +1,51 @@
    +import re
    +
     import pandas as pd
     
     import dtale.global_state as global_state
     
     from dtale.pandas_util import check_pandas_version, is_pandas3
     from dtale.utils import format_data, get_bool_arg
     
    +# Patterns that could enable code execution via pandas.DataFrame.query()
    +_DANGEROUS_QUERY_PATTERNS = re.compile(
    +    r"(__\w+__"  # dunder attributes (__import__, __class__, etc.)
    +    r"|(?<!\w)import\s*\("  # import() calls
    +    r"|(?<!\w)exec\s*\("  # exec() calls
    +    r"|(?<!\w)eval\s*\("  # eval() calls
    +    r"|(?<!\w)compile\s*\("  # compile() calls
    +    r"|(?<!\w)open\s*\("  # open() calls
    +    r"|(?<!\w)getattr\s*\("  # getattr() calls
    +    r"|(?<!\w)setattr\s*\("  # setattr() calls
    +    r"|(?<!\w)delattr\s*\("  # delattr() calls
    +    r"|(?<!\w)globals\s*\("  # globals() calls
    +    r"|(?<!\w)locals\s*\("  # locals() calls
    +    r"|(?<!\w)vars\s*\("  # vars() calls
    +    r"|(?<!\w)dir\s*\("  # dir() calls
    +    r"|(?<!\w)os\."  # os module access
    +    r"|(?<!\w)sys\."  # sys module access
    +    r"|(?<!\w)subprocess"  # subprocess module access
    +    r"|(?<!\w)shutil\."  # shutil module access
    +    r")",
    +    re.IGNORECASE,
    +)
    +
    +
    +def validate_query_safety(query):
    +    """Defense-in-depth validation of query strings before passing to DataFrame.query().
    +
    +    This catches dangerous patterns that could enable code execution, even if
    +    upstream filter construction is compromised.
    +    """
    +    if not query:
    +        return
    +    if _DANGEROUS_QUERY_PATTERNS.search(query):
    +        raise ValueError(
    +            "Query contains potentially unsafe content and has been blocked: {}".format(
    +                repr(query[:200])
    +            )
    +        )
    +
     
     def build_col_key(col):
         # Use backticks for pandas >= 0.25 to handle column names with spaces or protected words
    @@ -149,6 +190,8 @@ def _load_pct(df):
                 return _load_pct(df), []
             return _load_pct(df)
     
    +    validate_query_safety(query)
    +
         is_pandas25 = check_pandas_version("0.25.0")
         curr_app_settings = global_state.get_app_settings()
         engine = curr_app_settings.get("query_engine", "python")
    
  • tests/dtale/test_column_filters.py+165 4 modified
    @@ -15,7 +15,7 @@
         handle_ne,
     )
     from dtale.pandas_util import check_pandas_version
    -from dtale.query import run_query
    +from dtale.query import run_query, validate_query_safety
     
     
     @pytest.mark.unit
    @@ -232,10 +232,10 @@ def test_numeric_filter_operators():
         assert "==" in fltr["query"]
         assert len(run_query(df, fltr["query"])) == 1
     
    -    # Test unknown operand
    +    # Test unknown operand raises ValueError
         cfg = dict(operand="unknown", type="int")
    -    fltr = NumericFilter("foo", "I", cfg).build_filter()
    -    assert fltr is None
    +    with pytest.raises(ValueError, match="Invalid operand"):
    +        NumericFilter("foo", "I", cfg).build_filter()
     
     
     @pytest.mark.unit
    @@ -375,3 +375,164 @@ def test_numeric_filter_missing_and_populated():
         fltr = NumericFilter("foo", "I", cfg).build_filter()
         assert fltr is not None
         assert "~" in fltr["query"]
    +
    +
    +@pytest.mark.unit
    +def test_security_string_filter_injection():
    +    """Test that code injection attempts via string filters are blocked."""
    +    # __import__ in value
    +    cfg = dict(
    +        action="equals",
    +        operand="=",
    +        value=["__import__('os').system('rm -rf /')"],
    +        type="string",
    +    )
    +    with pytest.raises(ValueError, match="potentially unsafe"):
    +        StringFilter("foo", "S", cfg).build_filter()
    +
    +    # exec() in raw
    +    cfg = dict(action="startswith", operand="=", raw="exec('malicious')", type="string")
    +    with pytest.raises(ValueError, match="potentially unsafe"):
    +        StringFilter("foo", "S", cfg).build_filter()
    +
    +    # eval() in raw
    +    cfg = dict(action="contains", operand="=", raw="eval('bad')", type="string")
    +    with pytest.raises(ValueError, match="potentially unsafe"):
    +        StringFilter("foo", "S", cfg).build_filter()
    +
    +    # @ reference in raw (can access local variables in query scope)
    +    cfg = dict(action="contains", operand="=", raw="@var", type="string")
    +    with pytest.raises(ValueError, match="potentially unsafe"):
    +        StringFilter("foo", "S", cfg).build_filter()
    +
    +    # os module access
    +    cfg = dict(action="equals", operand="=", value=["os.system('ls')"], type="string")
    +    with pytest.raises(ValueError, match="potentially unsafe"):
    +        StringFilter("foo", "S", cfg).build_filter()
    +
    +    # Invalid action
    +    cfg = dict(action="__import__", operand="=", raw="test", type="string")
    +    with pytest.raises(ValueError, match="Invalid string filter action"):
    +        StringFilter("foo", "S", cfg).build_filter()
    +
    +    # Invalid operand
    +    cfg = dict(
    +        action="equals", operand="__import__('os')", value=["test"], type="string"
    +    )
    +    with pytest.raises(ValueError, match="Invalid operand"):
    +        StringFilter("foo", "S", cfg).build_filter()
    +
    +
    +@pytest.mark.unit
    +def test_security_numeric_filter_injection():
    +    """Test that code injection attempts via numeric filters are blocked."""
    +    # Non-numeric value
    +    cfg = dict(operand="=", value=["__import__('os')"], type="int")
    +    with pytest.raises(ValueError):
    +        NumericFilter("foo", "I", cfg).build_filter()
    +
    +    # Non-numeric comparison value
    +    cfg = dict(operand=">", value="exec('bad')", type="int")
    +    with pytest.raises(ValueError):
    +        NumericFilter("foo", "I", cfg).build_filter()
    +
    +    # Non-numeric min/max
    +    cfg = dict(operand="[]", min="__import__('os')", max=10, type="int")
    +    with pytest.raises(ValueError):
    +        NumericFilter("foo", "I", cfg).build_filter()
    +
    +
    +@pytest.mark.unit
    +def test_security_date_filter_injection():
    +    """Test that code injection attempts via date filters are blocked."""
    +    cfg = dict(start="__import__('os').system('ls')", type="date")
    +    with pytest.raises(ValueError, match="invalid characters"):
    +        DateFilter("date_col", "D", cfg).build_filter()
    +
    +    cfg = dict(end="'); os.system('ls", type="date")
    +    with pytest.raises(ValueError, match="invalid characters"):
    +        DateFilter("date_col", "D", cfg).build_filter()
    +
    +
    +@pytest.mark.unit
    +def test_security_outlier_filter_injection():
    +    """Test that code injection attempts via outlier filters are blocked."""
    +    cfg = dict(query="__import__('os').system('rm -rf /')", type="outliers")
    +    with pytest.raises(ValueError, match="potentially unsafe"):
    +        OutlierFilter("foo", "I", cfg).build_filter()
    +
    +    cfg = dict(query="exec('bad_code')", type="outliers")
    +    with pytest.raises(ValueError, match="potentially unsafe"):
    +        OutlierFilter("foo", "I", cfg).build_filter()
    +
    +
    +@pytest.mark.unit
    +def test_security_length_filter_injection():
    +    """Test that length filter validates numeric inputs."""
    +    # Non-numeric length value
    +    cfg = dict(action="length", operand="=", raw="exec('bad')", type="string")
    +    with pytest.raises(ValueError):
    +        StringFilter("foo", "S", cfg).build_filter()
    +
    +    # Non-numeric range values
    +    cfg = dict(action="length", operand="=", raw="1,exec('bad')", type="string")
    +    with pytest.raises(ValueError):
    +        StringFilter("foo", "S", cfg).build_filter()
    +
    +    # Valid length values should still work
    +    cfg = dict(action="length", operand="=", raw="3", type="string")
    +    fltr = StringFilter("foo", "S", cfg).build_filter()
    +    assert fltr is not None
    +
    +    cfg = dict(action="length", operand="=", raw="1,3", type="string")
    +    fltr = StringFilter("foo", "S", cfg).build_filter()
    +    assert fltr is not None
    +
    +
    +@pytest.mark.unit
    +def test_security_validate_query_safety():
    +    """Test the defense-in-depth query validation in run_query."""
    +    # These should raise ValueError
    +    with pytest.raises(ValueError, match="potentially unsafe"):
    +        validate_query_safety("__import__('os').system('ls')")
    +
    +    with pytest.raises(ValueError, match="potentially unsafe"):
    +        validate_query_safety("`col` == 1 and exec('bad')")
    +
    +    with pytest.raises(ValueError, match="potentially unsafe"):
    +        validate_query_safety("eval('malicious_code')")
    +
    +    with pytest.raises(ValueError, match="potentially unsafe"):
    +        validate_query_safety("os.system('rm -rf /')")
    +
    +    # These should pass (legitimate queries)
    +    validate_query_safety("`foo` == 1")
    +    validate_query_safety("`foo` in (1, 2, 3)")
    +    validate_query_safety(
    +        "`foo`.str.contains('bar', na=False, case=False, regex=False)"
    +    )
    +    validate_query_safety("`foo` >= '20200101' and `foo` <= '20200201'")
    +    validate_query_safety(None)
    +    validate_query_safety("")
    +
    +
    +@pytest.mark.unit
    +def test_security_valid_filters_still_work():
    +    """Ensure that legitimate filter operations are not blocked by security checks."""
    +    df = pd.DataFrame(dict(foo=["AAA", "BBB", "CCC"], bar=[1, 2, 3]))
    +
    +    # String equals
    +    cfg = dict(action="equals", operand="=", value=["AAA"], type="string")
    +    fltr = StringFilter("foo", "S", cfg).build_filter()
    +    assert len(run_query(df, fltr["query"])) == 1
    +
    +    # Numeric range
    +    cfg = dict(operand="[]", min=1, max=2, type="int")
    +    fltr = NumericFilter("bar", "I", cfg).build_filter()
    +    assert len(run_query(df, fltr["query"])) == 2
    +
    +    # Date filter
    +    cfg = dict(start="20200101", end="20200201", type="date")
    +    fltr = DateFilter("date_col", "D", cfg).build_filter()
    +    assert fltr is not None
    +    assert "query" in fltr
    

Vulnerability mechanics

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

References

4

News mentions

0

No linked articles in our index yet.