VYPR
High severityNVD Advisory· Published Mar 18, 2026· Updated Mar 18, 2026

Glances has a SQL Injection in DuckDB Export via Unparameterized DDL Statements

CVE-2026-32611

Description

Glances is an open-source system cross-platform monitoring tool. The GHSA-x46r fix (commit 39161f0) addressed SQL injection in the TimescaleDB export module by converting all SQL operations to use parameterized queries and psycopg.sql composable objects. However, the DuckDB export module (glances/exports/glances_duckdb/__init__.py) was not included in this fix and contains the same class of vulnerability: table names and column names derived from monitoring statistics are directly interpolated into SQL statements via f-strings. While DuckDB INSERT values already use parameterized queries (? placeholders), the DDL construction and table name references do not escape or parameterize identifier names. Version 4.5.3 provides a more complete fix.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
GlancesPyPI
< 4.5.24.5.2

Affected products

1

Patches

1
63b7da288952

Merge branch 'GHSA-49g7-2ww7-3vf5' into develop

https://github.com/nicolargo/glancesnicolargoMar 14, 2026via ghsa
6 files changed · +250 24
  • conf/glances.conf+8 0 modified
    @@ -928,6 +928,14 @@ host=nats://localhost:4222
     # Prefix for the subjects (default is 'glances')
     prefix=glances
     
    +[duckdb]
    +# database defines where data are stored, can be one of:
    +# :memory: (see https://duckdb.org/docs/stable/clients/python/dbapi#in-memory-connection)
    +# :memory:glances (see https://duckdb.org/docs/stable/clients/python/dbapi#in-memory-connection)
    +# /path/to/glances.db (see https://duckdb.org/docs/stable/clients/python/dbapi#file-based-connection)
    +# Or anyone else supported by the API (see https://duckdb.org/docs/stable/clients/python/dbapi)
    +database=:memory:
    +
     ##############################################################################
     # AMPS
     # * enable: Enable (true) or disable (false) the AMP
    
  • glances/exports/glances_duckdb/__init__.py+24 15 modified
    @@ -18,6 +18,16 @@
     from glances.exports.export import GlancesExport
     from glances.logger import logger
     
    +
    +def _quote_identifier(name):
    +    """Quote a SQL identifier to prevent injection.
    +
    +    DuckDB uses standard double-quote escaping for identifiers.
    +    Any embedded double-quote is doubled to escape it.
    +    """
    +    return '"' + str(name).replace('"', '""') + '"'
    +
    +
     # Define the type conversions for DuckDB
     # https://duckdb.org/docs/stable/clients/python/conversion
     convert_types = {
    @@ -112,10 +122,12 @@ def update(self, stats):
                 values_list = []  # List of values to insert (list of lists, one list per row)
                 if isinstance(plugin_stats, dict):
                     # Create the list to create the table
    -                creation_list.append('time TIMETZ')
    -                creation_list.append('hostname_id VARCHAR')
    +                creation_list.append(f'{_quote_identifier("time")} TIMETZ')
    +                creation_list.append(f'{_quote_identifier("hostname_id")} VARCHAR')
                     for key, value in plugin_stats.items():
    -                    creation_list.append(f"{key} {convert_types[type(self.normalize(value)).__name__]}")
    +                    creation_list.append(
    +                        f"{_quote_identifier(key)} {convert_types[type(self.normalize(value)).__name__]}"
    +                    )
                     # Create the list of values to insert
                     item_list = []
                     item_list.append(self.normalize(datetime.now().replace(microsecond=0)))
    @@ -124,11 +136,13 @@ def update(self, stats):
                     values_list = [item_list]
                 elif isinstance(plugin_stats, list) and len(plugin_stats) > 0 and 'key' in plugin_stats[0]:
                     # Create the list to create the table
    -                creation_list.append('time TIMETZ')
    -                creation_list.append('hostname_id VARCHAR')
    -                creation_list.append('key_id VARCHAR')
    +                creation_list.append(f'{_quote_identifier("time")} TIMETZ')
    +                creation_list.append(f'{_quote_identifier("hostname_id")} VARCHAR')
    +                creation_list.append(f'{_quote_identifier("key_id")} VARCHAR')
                     for key, value in plugin_stats[0].items():
    -                    creation_list.append(f"{key} {convert_types[type(self.normalize(value)).__name__]}")
    +                    creation_list.append(
    +                        f"{_quote_identifier(key)} {convert_types[type(self.normalize(value)).__name__]}"
    +                    )
                     # Create the list of values to insert
                     for plugin_item in plugin_stats:
                         item_list = []
    @@ -150,13 +164,11 @@ def export(self, plugin, creation_list, values_list):
             logger.debug(f"Export {plugin} stats to DuckDB")
     
             # Create the table if it does not exist
    +        quoted_plugin = _quote_identifier(plugin)
             table_list = [t[0] for t in self.client.sql("SHOW TABLES").fetchall()]
             if plugin not in table_list:
                 # Execute the create table query
    -            create_query = f"""
    -CREATE TABLE {plugin} (
    -{', '.join(creation_list)}
    -);"""
    +            create_query = f"CREATE TABLE {quoted_plugin} ({', '.join(creation_list)});"
                 logger.debug(f"Create table: {create_query}")
                 try:
                     self.client.execute(create_query)
    @@ -169,10 +181,7 @@ def export(self, plugin, creation_list, values_list):
     
             # Insert values into the table
             for values in values_list:
    -            insert_query = f"""
    -INSERT INTO {plugin} VALUES (
    -{', '.join(['?' for _ in values])}
    -);"""
    +            insert_query = f"INSERT INTO {quoted_plugin} VALUES ({', '.join(['?' for _ in values])});"
                 logger.debug(f"Insert values into table {plugin}: {values}")
                 try:
                     self.client.execute(insert_query, values)
    
  • pyproject.toml+2 1 modified
    @@ -75,7 +75,8 @@ containers = [
       "six",
     ]
     export = [
    -  "bernhard", # "cassandra-driver, may cause issue on Docker image, TODO: test",
    +  "bernhard",
    +  "duckdb",
       "elasticsearch",
       "graphitesender",
       "ibmcloudant",
    
  • tests-data/tools/duckdbcheck.py+2 2 modified
    @@ -21,15 +21,15 @@ def check_duckdb(input_file, expected_lines, expected_columns=None):
     
             # Check 1: Number of lines for CPU
             row_count = len(result)
    -        if row_count != expected_lines:
    +        if row_count < expected_lines:
                 print(f"Error: Expected {expected_lines} CPU lines, but found {row_count}")
                 return False
     
             result = db.sql("SELECT * from network").fetchall()
     
             # Check 2: Number of lines for Network
             row_count = len(result)
    -        if row_count != expected_lines:
    +        if row_count < expected_lines:
                 print(f"Error: Expected {expected_lines} Network lines, but found {row_count}")
                 return False
     
    
  • tests/test_duckdb_sanitize.py+192 0 added
    @@ -0,0 +1,192 @@
    +#!/usr/bin/env python
    +#
    +# Glances - An eye on your system
    +#
    +# SPDX-FileCopyrightText: 2025 Nicolas Hennion <nicolas@nicolargo.com>
    +#
    +# SPDX-License-Identifier: LGPL-3.0-only
    +#
    +
    +"""Glances unit tests for DuckDB export SQL injection prevention.
    +
    +Tests cover:
    +- _quote_identifier properly escapes SQL identifiers
    +- CREATE TABLE and INSERT INTO use quoted identifiers
    +- SQL injection via crafted column names is prevented
    +- SQL injection via crafted table names is prevented
    +- Normal export workflow still works with quoting
    +"""
    +
    +import pytest
    +
    +try:
    +    import duckdb
    +except ImportError:
    +    pytest.skip("duckdb not installed", allow_module_level=True)
    +
    +from glances.exports.glances_duckdb import _quote_identifier
    +
    +# ---------------------------------------------------------------------------
    +# Tests – _quote_identifier
    +# ---------------------------------------------------------------------------
    +
    +
    +class TestQuoteIdentifier:
    +    """Unit tests for the _quote_identifier helper."""
    +
    +    def test_simple_name(self):
    +        assert _quote_identifier('cpu_percent') == '"cpu_percent"'
    +
    +    def test_name_with_spaces(self):
    +        assert _quote_identifier('my column') == '"my column"'
    +
    +    def test_name_with_double_quote(self):
    +        """Embedded double quotes must be doubled."""
    +        assert _quote_identifier('col"name') == '"col""name"'
    +
    +    def test_name_with_multiple_double_quotes(self):
    +        assert _quote_identifier('a"b"c') == '"a""b""c"'
    +
    +    def test_sql_injection_attempt(self):
    +        """SQL metacharacters must be safely quoted."""
    +        malicious = 'cpu); DROP TABLE secrets; --'
    +        quoted = _quote_identifier(malicious)
    +        assert quoted == '"cpu); DROP TABLE secrets; --"'
    +
    +    def test_empty_string(self):
    +        assert _quote_identifier('') == '""'
    +
    +    def test_non_string_input(self):
    +        """Non-string input should be converted to string."""
    +        assert _quote_identifier(42) == '"42"'
    +
    +    def test_name_with_semicolon(self):
    +        assert _quote_identifier('col;name') == '"col;name"'
    +
    +    def test_name_with_parentheses(self):
    +        assert _quote_identifier('col(name)') == '"col(name)"'
    +
    +
    +# ---------------------------------------------------------------------------
    +# Tests – SQL injection prevention with real DuckDB
    +# ---------------------------------------------------------------------------
    +
    +
    +class TestDuckDBInjectionPrevention:
    +    """Verify that quoted identifiers prevent SQL injection in real DuckDB."""
    +
    +    @pytest.fixture
    +    def db(self):
    +        """Create an in-memory DuckDB connection."""
    +        conn = duckdb.connect(':memory:')
    +        yield conn
    +        conn.close()
    +
    +    def test_create_table_with_safe_names(self, db):
    +        """Normal table and column creation works with quoting."""
    +        table = _quote_identifier('cpu')
    +        col1 = _quote_identifier('time')
    +        col2 = _quote_identifier('cpu_percent')
    +        db.execute(f'CREATE TABLE {table} ({col1} VARCHAR, {col2} DOUBLE);')
    +        db.execute(f'INSERT INTO {table} VALUES (?, ?);', ['2024-01-01', 95.5])
    +        result = db.execute(f'SELECT * FROM {table}').fetchall()
    +        assert len(result) == 1
    +        assert result[0] == ('2024-01-01', 95.5)
    +
    +    def test_create_table_with_special_column_names(self, db):
    +        """Column names with special characters are properly handled."""
    +        table = _quote_identifier('test_plugin')
    +        col_special = _quote_identifier('my column with spaces')
    +        db.execute(f'CREATE TABLE {table} ({col_special} VARCHAR);')
    +        db.execute(f'INSERT INTO {table} VALUES (?);', ['value'])
    +        result = db.execute(f'SELECT * FROM {table}').fetchall()
    +        assert result[0] == ('value',)
    +
    +    def test_injection_in_column_name_is_neutralized(self, db):
    +        """A malicious column name must not execute injected SQL."""
    +        # Create a target table that the injection would try to drop
    +        db.execute('CREATE TABLE secrets (data VARCHAR);')
    +        db.execute("INSERT INTO secrets VALUES ('sensitive');")
    +
    +        # Attempt injection via column name
    +        malicious_col = 'cpu BIGINT); DROP TABLE secrets; --'
    +        safe_col = _quote_identifier(malicious_col)
    +        table = _quote_identifier('test_inject')
    +
    +        # This should create a table with a weird column name, NOT drop secrets
    +        db.execute(f'CREATE TABLE {table} ({safe_col} VARCHAR);')
    +
    +        # Verify secrets table still exists and has data
    +        result = db.execute('SELECT * FROM secrets').fetchall()
    +        assert result == [('sensitive',)]
    +
    +    def test_injection_in_table_name_is_neutralized(self, db):
    +        """A malicious table name must not execute injected SQL."""
    +        db.execute('CREATE TABLE important (data VARCHAR);')
    +        db.execute("INSERT INTO important VALUES ('keep');")
    +
    +        malicious_table = 'x (a INT); DROP TABLE important; --'
    +        safe_table = _quote_identifier(malicious_table)
    +        db.execute(f'CREATE TABLE {safe_table} (col1 VARCHAR);')
    +
    +        # important table must still exist
    +        result = db.execute('SELECT * FROM important').fetchall()
    +        assert result == [('keep',)]
    +
    +    def test_insert_with_quoted_table(self, db):
    +        """INSERT INTO with quoted table name works correctly."""
    +        table = _quote_identifier('my-plugin')
    +        db.execute(f'CREATE TABLE {table} ({_quote_identifier("val")} BIGINT);')
    +        db.execute(f'INSERT INTO {table} VALUES (?);', [42])
    +        result = db.execute(f'SELECT * FROM {table}').fetchall()
    +        assert result == [(42,)]
    +
    +    def test_full_export_simulation(self, db):
    +        """Simulate a full Glances DuckDB export cycle with quoting."""
    +        plugin = 'cpu'
    +        stats = {
    +            'total': 85.5,
    +            'user': 60.0,
    +            'system': 25.5,
    +            'idle': 14.5,
    +        }
    +        convert_types = {
    +            'float': 'DOUBLE',
    +            'int': 'BIGINT',
    +            'str': 'VARCHAR',
    +        }
    +
    +        # Build creation_list as the real code does
    +        creation_list = [
    +            f'{_quote_identifier("time")} VARCHAR',
    +            f'{_quote_identifier("hostname_id")} VARCHAR',
    +        ]
    +        for key, value in stats.items():
    +            creation_list.append(f'{_quote_identifier(key)} {convert_types[type(value).__name__]}')
    +
    +        # CREATE TABLE
    +        quoted_plugin = _quote_identifier(plugin)
    +        create_query = f'CREATE TABLE {quoted_plugin} ({", ".join(creation_list)});'
    +        db.execute(create_query)
    +
    +        # INSERT
    +        values = ['2024-01-01T00:00:00', 'myhost'] + list(stats.values())
    +        placeholders = ', '.join(['?' for _ in values])
    +        insert_query = f'INSERT INTO {quoted_plugin} VALUES ({placeholders});'
    +        db.execute(insert_query, values)
    +
    +        # Verify
    +        result = db.execute(f'SELECT * FROM {quoted_plugin}').fetchall()
    +        assert len(result) == 1
    +        assert result[0][0] == '2024-01-01T00:00:00'
    +        assert result[0][1] == 'myhost'
    +        assert result[0][2] == 85.5
    +
    +    def test_column_with_double_quote_in_name(self, db):
    +        """Column name containing double quotes is properly escaped."""
    +        table = _quote_identifier('test')
    +        col = _quote_identifier('col"with"quotes')
    +        db.execute(f'CREATE TABLE {table} ({col} VARCHAR);')
    +        db.execute(f'INSERT INTO {table} VALUES (?);', ['value'])
    +        result = db.execute(f'SELECT * FROM {table}').fetchall()
    +        assert result == [('value',)]
    
  • tests/test_export_duckdb.sh+22 6 modified
    @@ -3,16 +3,32 @@
     # Exit on error
     set -e
     
    -# Remove previous test database
    -echo "Remove previous test database..."
    -rm -f /tmp/glances.db
    +# Paths
    +DEFAULT_CONF="./conf/glances.conf"
    +CUSTOM_CONF="/tmp/glances_duckdb_test.conf"
    +DUCKDB_FILE="/tmp/glances.db"
    +
    +# Remove previous test artifacts
    +echo "Remove previous test database and config..."
    +rm -f "$DUCKDB_FILE"
    +rm -f "$CUSTOM_CONF"
    +
    +# Generate a custom config from the default one,
    +# replacing database=:memory: with a file-based database
    +echo "Generate custom config from ${DEFAULT_CONF}..."
    +sed 's|^database=:memory:$|database=/tmp/glances.db|' "$DEFAULT_CONF" > "$CUSTOM_CONF"
     
     # Run glances with export to DuckDB, stopping after 10 writes
     # This will run synchronously now since we're using --stop-after
     echo "Glances to export system stats to DuckDB (duration: ~ 20 seconds)"
    -.venv/bin/python -m glances --config ./conf/glances.conf --export duckdb --stop-after 10 --quiet
    +.venv/bin/python -m glances --config "$CUSTOM_CONF" --export duckdb --stop-after 10 --quiet
     
     echo "Checking DuckDB database..."
    -.venv/bin/python ./tests-data/tools/duckdbcheck.py -i /tmp/glances.db -l 9
    +.venv/bin/python ./tests-data/tools/duckdbcheck.py -i "$DUCKDB_FILE" -l 9
    +
    +# Cleanup
    +echo "Cleanup test artifacts..."
    +rm -f "$DUCKDB_FILE"
    +rm -f "$CUSTOM_CONF"
     
    -echo "Script completed successfully!"
    \ No newline at end of file
    +echo "Script completed successfully!"
    

Vulnerability mechanics

Generated by null/stub 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.