Security advisory GHSA-49g7-2ww7-3vf5: Glances has a SQL Injection in DuckDB Export via Unparameterized DDL Statements
Package: glances (pypi), vulnerable version: 4.5.1, fixed in: 4.5.2
CWE: CWE-89
Files changed by the fix:
glances/exports/glances_duckdb/__init__.py
tests-data/tools/duckdbcheck.py

Fix diff:
diff --git a/conf/glances.conf b/conf/glances.conf
index d516fd0913..9c1b47d99c 100644
--- a/conf/glances.conf
+++ b/conf/glances.conf
@@ -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
diff --git a/glances/exports/glances_duckdb/__init__.py b/glances/exports/glances_duckdb/__init__.py
index 4ec061f0db..dd32e6a490 100644
--- a/glances/exports/glances_duckdb/__init__.py
+++ b/glances/exports/glances_duckdb/__init__.py
@@ -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)
diff --git a/pyproject.toml b/pyproject.toml
index f38f1a48cc..468bce9c92 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -75,7 +75,8 @@ containers = [
   "six",
 ]
 export = [
-  "bernhard", # "cassandra-driver, may cause issue on Docker image, TODO: test",
+  "bernhard",
+  "duckdb",
   "elasticsearch",
   "graphitesender",
   "ibmcloudant",
diff --git a/tests-data/tools/duckdbcheck.py b/tests-data/tools/duckdbcheck.py
index 8cdce1b3f9..de5ea8845f 100755
--- a/tests-data/tools/duckdbcheck.py
+++ b/tests-data/tools/duckdbcheck.py
@@ -21,7 +21,7 @@ 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
 
@@ -29,7 +29,7 @@ def check_duckdb(input_file, expected_lines, expected_columns=None):
 
         # 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
 
diff --git a/tests/test_duckdb_sanitize.py b/tests/test_duckdb_sanitize.py
new file mode 100755
index 0000000000..930a1598ab
--- /dev/null
+++ b/tests/test_duckdb_sanitize.py
@@ -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',)]
diff --git a/tests/test_export_duckdb.sh b/tests/test_export_duckdb.sh
index 888ea8004b..c1d190b468 100755
--- a/tests/test_export_duckdb.sh
+++ b/tests/test_export_duckdb.sh
@@ -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!"