new file mode 100644
@@ -0,0 +1,65 @@
+From 1e872e301436efa5d3fcd54e7628ac82c57698d2 Mon Sep 17 00:00:00 2001
+From: =?UTF-8?q?B=C3=A1lint=20Horv=C3=A1th?= <bal.horv.98@gmail.com>
+Date: Tue, 7 Apr 2026 14:40:17 +0200
+Subject: [PATCH] afsql: Fixed SQL injection bug
+MIME-Version: 1.0
+Content-Type: text/plain; charset=UTF-8
+Content-Transfer-Encoding: 8bit
+
+Signed-off-by: Bálint Horváth <bal.horv.98@gmail.com>
+Signed-off-by: Hofi <hofione@gmail.com>
+
+CVE: CVE-2026-39879
+Upstream-Status: Backport [https://github.com/syslog-ng/syslog-ng/commit/1e872e301436efa5d3fcd54e7628ac82c57698d2]
+Signed-off-by: Peter Marko <peter.marko@siemens.com>
+---
+ modules/afsql/afsql.c | 12 +++++++++---
+ 1 file changed, 9 insertions(+), 3 deletions(-)
+
+diff --git a/modules/afsql/afsql.c b/modules/afsql/afsql.c
+index be59a2352..fb7ef3b32 100644
+--- a/modules/afsql/afsql.c
++++ b/modules/afsql/afsql.c
+@@ -36,6 +36,8 @@
+
+ #include <string.h>
+ #include <errno.h>
++#include <utf8utils.h>
++
+ #include "compat/openssl_support.h"
+ #include <openssl/evp.h>
+
+@@ -230,11 +232,12 @@ static gboolean
+ afsql_dd_run_query(AFSqlDestDriver *self, const gchar *query, gboolean silent, dbi_result *result)
+ {
+ dbi_result db_res;
++ gchar* escaped_query = convert_unsafe_utf8_to_escaped_text(query, -1, 0);
+
+ msg_debug("Running SQL query",
+- evt_tag_str("query", query));
++ evt_tag_str("query", escaped_query));
+
+- db_res = dbi_conn_query(self->dbi_ctx, query);
++ db_res = dbi_conn_query(self->dbi_ctx, escaped_query);
+ if (!db_res)
+ {
+ const gchar *dbi_error;
+@@ -249,14 +252,17 @@ afsql_dd_run_query(AFSqlDestDriver *self, const gchar *query, gboolean silent, d
+ evt_tag_str("user", self->user),
+ evt_tag_str("database", self->database),
+ evt_tag_str("error", dbi_error),
+- evt_tag_str("query", query));
++ evt_tag_str("query", query),
++ evt_tag_str("escaped_query", escaped_query));
+ }
++ g_free(escaped_query);
+ return FALSE;
+ }
+ if (result)
+ *result = db_res;
+ else
+ dbi_result_free(db_res);
++ g_free(escaped_query);
+ return TRUE;
+ }
+
new file mode 100644
@@ -0,0 +1,571 @@
+From 1aba7537f0c406090f98a649475acbf517440220 Mon Sep 17 00:00:00 2001
+From: =?UTF-8?q?B=C3=A1lint=20Horv=C3=A1th?= <bal.horv.98@gmail.com>
+Date: Tue, 7 Apr 2026 16:58:51 +0200
+Subject: [PATCH] afsql: Added tests for SQL query
+MIME-Version: 1.0
+Content-Type: text/plain; charset=UTF-8
+Content-Transfer-Encoding: 8bit
+
+Signed-off-by: Hofi <hofione@gmail.com>
+Signed-off-by: Bálint Horváth <bal.horv.98@gmail.com>
+Signed-off-by: Hofi <hofione@gmail.com>
+
+CVE: CVE-2026-39879
+Upstream-Status: Backport [https://github.com/syslog-ng/syslog-ng/commit/1aba7537f0c406090f98a649475acbf517440220]
+Signed-off-by: Peter Marko <peter.marko@siemens.com>
+---
+ modules/afsql/CMakeLists.txt | 1 +
+ modules/afsql/Makefile.am | 5 +-
+ modules/afsql/afsql.c | 2 +-
+ modules/afsql/tests/CMakeLists.txt | 9 +
+ modules/afsql/tests/Makefile.am | 30 +++
+ modules/afsql/tests/mock-dbi.c | 129 ++++++++++++
+ modules/afsql/tests/mock-dbi.h | 41 ++++
+ modules/afsql/tests/test_afsql.h | 31 +++
+ modules/afsql/tests/test_afsql_run_query.c | 225 +++++++++++++++++++++
+ 9 files changed, 471 insertions(+), 2 deletions(-)
+ create mode 100644 modules/afsql/tests/CMakeLists.txt
+ create mode 100644 modules/afsql/tests/Makefile.am
+ create mode 100644 modules/afsql/tests/mock-dbi.c
+ create mode 100644 modules/afsql/tests/mock-dbi.h
+ create mode 100644 modules/afsql/tests/test_afsql.h
+ create mode 100644 modules/afsql/tests/test_afsql_run_query.c
+
+diff --git a/modules/afsql/CMakeLists.txt b/modules/afsql/CMakeLists.txt
+index 0d336f3a7..8921bbf33 100644
+--- a/modules/afsql/CMakeLists.txt
++++ b/modules/afsql/CMakeLists.txt
+@@ -24,3 +24,4 @@ add_module(
+ SOURCES ${AFSQL_SOURCES}
+ )
+
++add_test_subdirectory(tests)
+diff --git a/modules/afsql/Makefile.am b/modules/afsql/Makefile.am
+index afc81655d..dd3dd9b5a 100644
+--- a/modules/afsql/Makefile.am
++++ b/modules/afsql/Makefile.am
+@@ -33,6 +33,9 @@ BUILT_SOURCES += \
+ modules/afsql/afsql-grammar.h
+ EXTRA_DIST += \
+ modules/afsql/afsql-grammar.ym \
+- modules/afsql/CMakeLists.txt
++ modules/afsql/CMakeLists.txt \
++ modules/afsql/tests/CMakeLists.txt
+
+ .PHONY: modules/afsql/ mod-afsql mod-sql
++
++include modules/afsql/tests/Makefile.am
+diff --git a/modules/afsql/afsql.c b/modules/afsql/afsql.c
+index fb7ef3b32..2234acfe6 100644
+--- a/modules/afsql/afsql.c
++++ b/modules/afsql/afsql.c
+@@ -228,7 +228,7 @@ afsql_dd_set_create_statement_append(LogDriver *s, const gchar *create_statement
+ *
+ * NOTE: This function can only be called from the database thread.
+ **/
+-static gboolean
++gboolean
+ afsql_dd_run_query(AFSqlDestDriver *self, const gchar *query, gboolean silent, dbi_result *result)
+ {
+ dbi_result db_res;
+diff --git a/modules/afsql/tests/CMakeLists.txt b/modules/afsql/tests/CMakeLists.txt
+new file mode 100644
+index 000000000..12396f58f
+--- /dev/null
++++ b/modules/afsql/tests/CMakeLists.txt
+@@ -0,0 +1,9 @@
++if(ENABLE_SQL)
++ add_unit_test(
++ CRITERION LIBTEST
++ TARGET test_afsql_run_query
++ SOURCES test_afsql_run_query.c ../afsql.c mock-dbi.c
++ INCLUDES ${CMAKE_CURRENT_SOURCE_DIR}/.. ${LIBDBI_INCLUDE_DIRS}
++ DEPENDS OpenSSL::SSL
++ )
++endif()
+diff --git a/modules/afsql/tests/Makefile.am b/modules/afsql/tests/Makefile.am
+new file mode 100644
+index 000000000..96e0c1476
+--- /dev/null
++++ b/modules/afsql/tests/Makefile.am
+@@ -0,0 +1,30 @@
++if ENABLE_SQL
++
++modules_afsql_tests_TESTS = \
++ modules/afsql/tests/test_afsql_run_query
++
++check_PROGRAMS += ${modules_afsql_tests_TESTS}
++
++modules_afsql_tests_test_afsql_run_query_SOURCES = \
++ modules/afsql/tests/mock-dbi.h \
++ modules/afsql/tests/test_afsql.h \
++ modules/afsql/tests/test_afsql_run_query.c \
++ modules/afsql/afsql.c \
++ modules/afsql/tests/mock-dbi.c
++
++modules_afsql_tests_test_afsql_run_query_CFLAGS = \
++ $(TEST_CFLAGS) \
++ -I$(top_srcdir)/modules/afsql \
++ -I$(top_srcdir)/modules/afsql/tests \
++ $(LIBDBI_CFLAGS)
++
++modules_afsql_tests_test_afsql_run_query_LDADD = \
++ $(TEST_LDADD) \
++ $(OPENSSL_LIBS)
++
++modules_afsql_tests_test_afsql_run_query_LDFLAGS = \
++ $(LDFLAGS)
++
++endif
++
++EXTRA_DIST += modules/afsql/tests/CMakeLists.txt
+diff --git a/modules/afsql/tests/mock-dbi.c b/modules/afsql/tests/mock-dbi.c
+new file mode 100644
+index 000000000..af00dec07
+--- /dev/null
++++ b/modules/afsql/tests/mock-dbi.c
+@@ -0,0 +1,129 @@
++/*
++ * Copyright (c) 2026 One Identity LLC.
++ *
++ * This program is free software; you can redistribute it and/or modify it
++ * under the terms of the GNU General Public License version 2 as published
++ * by the Free Software Foundation, or (at your option) any later version.
++ *
++ * This program is distributed in the hope that it will be useful,
++ * but WITHOUT ANY WARRANTY; without even the implied warranty of
++ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
++ * GNU General Public License for more details.
++ *
++ * You should have received a copy of the GNU General Public License
++ * along with this program; if not, write to the Free Software
++ * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
++ *
++ * As an additional exemption you are allowed to compile & link against the
++ * OpenSSL libraries as published by the OpenSSL project. See the file
++ * COPYING for details.
++ *
++ */
++
++#include "mock-dbi.h"
++#include <stdlib.h>
++
++dbi_result mock_dbi_query_result = (dbi_result) 0x1;
++gchar *mock_dbi_last_query = NULL;
++gboolean mock_dbi_result_freed = FALSE;
++const gchar *mock_dbi_error_message = "mock dbi error";
++
++void
++mock_dbi_reset(void)
++{
++ mock_dbi_query_result = (dbi_result) 0x1;
++ g_free(mock_dbi_last_query);
++ mock_dbi_last_query = NULL;
++ mock_dbi_result_freed = FALSE;
++ mock_dbi_error_message = "mock dbi error";
++}
++
++/* --- mocked functions (state-bearing) --- */
++
++dbi_result
++dbi_conn_query(dbi_conn conn, const char *statement)
++{
++ g_free(mock_dbi_last_query);
++ mock_dbi_last_query = g_strdup(statement);
++ return mock_dbi_query_result;
++}
++
++int
++dbi_conn_error(dbi_conn conn, const char **errmsg)
++{
++ if (errmsg)
++ *errmsg = mock_dbi_error_message;
++ return 0;
++}
++
++int
++dbi_result_free(dbi_result result)
++{
++ mock_dbi_result_freed = TRUE;
++ return 0;
++}
++
++/* --- no-op stubs for the rest of afsql.c --- */
++
++dbi_conn
++dbi_conn_new_r(const char *name, dbi_inst inst)
++{
++ return NULL;
++}
++
++int
++dbi_conn_connect(dbi_conn conn)
++{
++ return -1;
++}
++
++void
++dbi_conn_close(dbi_conn conn)
++{
++}
++
++int
++dbi_conn_ping(dbi_conn conn)
++{
++ return 0;
++}
++
++int
++dbi_conn_set_option(dbi_conn conn, const char *key, const char *value)
++{
++ return 0;
++}
++
++int
++dbi_conn_set_option_numeric(dbi_conn conn, const char *key, int value)
++{
++ return 0;
++}
++
++size_t
++dbi_conn_quote_string_copy(dbi_conn conn, const char *orig, char **dest)
++{
++ if (dest)
++ *dest = NULL;
++ return 0;
++}
++
++size_t
++dbi_conn_quote_binary_copy(dbi_conn conn, const unsigned char *orig, size_t length, unsigned char **dest)
++{
++ if (dest)
++ *dest = NULL;
++ return 0;
++}
++
++int
++dbi_initialize_r(const char *driverdir, dbi_inst *pInst)
++{
++ return 0;
++}
++
++unsigned int
++dbi_result_get_field_idx(dbi_result result, const char *fieldname)
++{
++ return 0;
++}
+diff --git a/modules/afsql/tests/mock-dbi.h b/modules/afsql/tests/mock-dbi.h
+new file mode 100644
+index 000000000..0f9ca800a
+--- /dev/null
++++ b/modules/afsql/tests/mock-dbi.h
+@@ -0,0 +1,41 @@
++/*
++ * Copyright (c) 2026 One Identity LLC.
++ *
++ * This program is free software; you can redistribute it and/or modify it
++ * under the terms of the GNU General Public License version 2 as published
++ * by the Free Software Foundation, or (at your option) any later version.
++ *
++ * This program is distributed in the hope that it will be useful,
++ * but WITHOUT ANY WARRANTY; without even the implied warranty of
++ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
++ * GNU General Public License for more details.
++ *
++ * You should have received a copy of the GNU General Public License
++ * along with this program; if not, write to the Free Software
++ * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
++ *
++ * As an additional exemption you are allowed to compile & link against the
++ * OpenSSL libraries as published by the OpenSSL project. See the file
++ * COPYING for details.
++ *
++ */
++
++#ifndef MOCK_DBI_H_INCLUDED
++#define MOCK_DBI_H_INCLUDED
++
++#pragma GCC diagnostic ignored "-Wstrict-prototypes"
++
++#include <dbi.h>
++#include <glib.h>
++
++/*
++ * Mock state controlling dbi_conn_query behaviour.
++ */
++extern dbi_result mock_dbi_query_result;
++extern gchar *mock_dbi_last_query;
++extern gboolean mock_dbi_result_freed;
++extern const gchar *mock_dbi_error_message;
++
++void mock_dbi_reset(void);
++
++#endif /* MOCK_DBI_H_INCLUDED */
+diff --git a/modules/afsql/tests/test_afsql.h b/modules/afsql/tests/test_afsql.h
+new file mode 100644
+index 000000000..804e815e3
+--- /dev/null
++++ b/modules/afsql/tests/test_afsql.h
+@@ -0,0 +1,31 @@
++/*
++ * Copyright (c) 2026 One Identity LLC.
++ *
++ * This program is free software; you can redistribute it and/or modify it
++ * under the terms of the GNU General Public License version 2 as published
++ * by the Free Software Foundation, or (at your option) any later version.
++ *
++ * This program is distributed in the hope that it will be useful,
++ * but WITHOUT ANY WARRANTY; without even the implied warranty of
++ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
++ * GNU General Public License for more details.
++ *
++ * You should have received a copy of the GNU General Public License
++ * along with this program; if not, write to the Free Software
++ * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
++ *
++ * As an additional exemption you are allowed to compile & link against the
++ * OpenSSL libraries as published by the OpenSSL project. See the file
++ * COPYING for details.
++ *
++ */
++
++#ifndef TEST_AFSQL_H_INCLUDED
++#define TEST_AFSQL_H_INCLUDED
++
++#include "afsql.h"
++
++gboolean afsql_dd_run_query(AFSqlDestDriver *self, const gchar *query, gboolean silent,
++ dbi_result *result);
++
++#endif /* TEST_AFSQL_H_INCLUDED */
+diff --git a/modules/afsql/tests/test_afsql_run_query.c b/modules/afsql/tests/test_afsql_run_query.c
+new file mode 100644
+index 000000000..b26f631a7
+--- /dev/null
++++ b/modules/afsql/tests/test_afsql_run_query.c
+@@ -0,0 +1,225 @@
++/*
++ * Copyright (c) 2026 One Identity LLC.
++ *
++ * This program is free software; you can redistribute it and/or modify it
++ * under the terms of the GNU General Public License version 2 as published
++ * by the Free Software Foundation, or (at your option) any later version.
++ *
++ * This program is distributed in the hope that it will be useful,
++ * but WITHOUT ANY WARRANTY; without even the implied warranty of
++ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
++ * GNU General Public License for more details.
++ *
++ * You should have received a copy of the GNU General Public License
++ * along with this program; if not, write to the Free Software
++ * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
++ *
++ * As an additional exemption you are allowed to compile & link against the
++ * OpenSSL libraries as published by the OpenSSL project. See the file
++ * COPYING for details.
++ *
++ */
++
++#include <criterion/criterion.h>
++#include <criterion/parameterized.h>
++
++#include "test_afsql.h"
++#include "mock-dbi.h"
++#include "apphook.h"
++
++/* ----------------------------- helpers ----------------------------- */
++
++static AFSqlDestDriver *
++_create_driver(void)
++{
++ AFSqlDestDriver *self = g_new0(AFSqlDestDriver, 1);
++ self->type = g_strdup("mysql");
++ self->host = g_strdup("localhost");
++ self->port = g_strdup("3306");
++ self->user = g_strdup("testuser");
++ self->database = g_strdup("testdb");
++ /* dbi_ctx is intentionally left NULL; mock-dbi ignores the conn handle */
++ return self;
++}
++
++static void
++_free_driver(AFSqlDestDriver *self)
++{
++ g_free(self->type);
++ g_free(self->host);
++ g_free(self->port);
++ g_free(self->user);
++ g_free(self->database);
++ g_free(self);
++}
++
++/* ----------------------------- fixtures ----------------------------- */
++
++static AFSqlDestDriver *driver;
++
++static void
++setup(void)
++{
++ app_startup();
++ mock_dbi_reset();
++ driver = _create_driver();
++}
++
++static void
++teardown(void)
++{
++ mock_dbi_reset();
++ _free_driver(driver);
++ app_shutdown();
++}
++
++TestSuite(afsql_dd_run_query, .init = setup, .fini = teardown);
++
++/* ----------------------------- tests -------------------------------- */
++
++Test(afsql_dd_run_query, successful_query_returns_true)
++{
++ mock_dbi_query_result = (dbi_result) 0x1;
++
++ gboolean ret = afsql_dd_run_query(driver, "SELECT 1", FALSE, NULL);
++
++ cr_assert(ret, "Expected TRUE on successful query");
++}
++
++Test(afsql_dd_run_query, failed_query_returns_false)
++{
++ mock_dbi_query_result = NULL;
++
++ gboolean ret = afsql_dd_run_query(driver, "SELECT 1", FALSE, NULL);
++
++ cr_assert_not(ret, "Expected FALSE when dbi_conn_query returns NULL");
++}
++
++Test(afsql_dd_run_query, result_pointer_is_populated_on_success)
++{
++ dbi_result sentinel = (dbi_result) 0xdeadbeef;
++ mock_dbi_query_result = sentinel;
++
++ dbi_result out = NULL;
++ afsql_dd_run_query(driver, "SELECT 1", FALSE, &out);
++
++ cr_assert_eq(out, sentinel,
++ "Expected *result to hold the dbi_result returned by dbi_conn_query");
++}
++
++Test(afsql_dd_run_query, result_is_freed_when_no_pointer_given)
++{
++ mock_dbi_query_result = (dbi_result) 0x1;
++ mock_dbi_result_freed = FALSE;
++
++ afsql_dd_run_query(driver, "SELECT 1", FALSE, NULL);
++
++ cr_assert(mock_dbi_result_freed,
++ "Expected dbi_result_free() to be called when result pointer is NULL");
++}
++
++Test(afsql_dd_run_query, result_is_not_freed_when_pointer_given)
++{
++ mock_dbi_query_result = (dbi_result) 0x1;
++ mock_dbi_result_freed = FALSE;
++
++ dbi_result out = NULL;
++ afsql_dd_run_query(driver, "SELECT 1", FALSE, &out);
++
++ cr_assert_not(mock_dbi_result_freed,
++ "Expected dbi_result_free() NOT to be called when result pointer is provided");
++}
++
++Test(afsql_dd_run_query, silent_mode_suppresses_error_on_failure)
++{
++ mock_dbi_query_result = NULL;
++
++ /* Should not crash or assert even though the query fails */
++ gboolean ret = afsql_dd_run_query(driver, "BAD QUERY", TRUE, NULL);
++
++ cr_assert_not(ret, "Expected FALSE on failure regardless of silent flag");
++}
++
++/*
++ * Parameterized regression tests for the SQL injection fix (commit e9dbd15bf).
++ *
++ * Each entry describes a raw query string and the exact string that must
++ * arrive at dbi_conn_query after convert_unsafe_utf8_to_escaped_text has
++ * processed it. A NULL expected_query means the input is safe ASCII and
++ * must pass through byte-for-byte unchanged.
++ */
++typedef struct
++{
++ gchar description[64];
++ gchar raw_query[128];
++ gchar expected_query[128];
++ gboolean expected_query_is_null; /* TRUE → identical to raw_query */
++} QuerySanitizationParam;
++
++ParameterizedTestParameters(afsql_dd_run_query, query_sanitization)
++{
++ static QuerySanitizationParam params[] =
++ {
++ /* safe inputs — must be forwarded unchanged */
++ { "plain ascii select", "SELECT 1", "", TRUE },
++ { "insert with safe string value", "INSERT INTO logs (msg) VALUES ('hello world')", "", TRUE },
++ { "query with numbers and underscores", "SELECT id, log_level FROM events WHERE id = 42", "", TRUE },
++
++ /* control characters that must be escaped */
++ { "bell character \\x07", "SELECT '\x07'", "SELECT '\\x07'", FALSE },
++ { "newline", "INSERT INTO t (v) VALUES ('line1\nline2')", "INSERT INTO t (v) VALUES ('line1\\nline2')", FALSE },
++ { "carriage return", "INSERT INTO t (v) VALUES ('a\rb')", "INSERT INTO t (v) VALUES ('a\\rb')", FALSE },
++ { "tab", "INSERT INTO t (v) VALUES ('col1\tcol2')", "INSERT INTO t (v) VALUES ('col1\\tcol2')", FALSE },
++ { "backspace", "INSERT INTO t (v) VALUES ('\b')", "INSERT INTO t (v) VALUES ('\\b')", FALSE },
++ { "form feed", "INSERT INTO t (v) VALUES ('\f')", "INSERT INTO t (v) VALUES ('\\f')", FALSE },
++ { "soh \\x01 unnamed control char", "SELECT '\x01'", "SELECT '\\x01'", FALSE },
++ { "unit separator \\x1f", "SELECT '\x1f'", "SELECT '\\x1f'", FALSE },
++ { "multiple consecutive control chars", "\x01\x02\x03", "\\x01\\x02\\x03", FALSE },
++ { "backslash is doubled", "SELECT '\\'", "SELECT '\\\\'", FALSE },
++
++ /* a literal backslash-n in the input (two chars: \ + n) must become \\n,
++ * not be re-interpreted as a newline escape */
++ { "pre-escaped \\n is not re-interpreted", "SELECT '\\n'", "SELECT '\\\\n'", FALSE },
++
++ /* invalid / overlong UTF-8 sequences */
++ { "invalid utf-8 byte \\xad is hex-escaped", "SELECT '\xad'", "SELECT '\\\\xad'", FALSE },
++ { "invalid utf-8 byte surrounded by valid", "SELECT 'Á\xadÉ'", "SELECT 'Á\\\\xadÉ'", FALSE },
++ { "truncated 2-byte utf-8 start byte", "SELECT '\xc3'", "SELECT '\\\\xc3'", FALSE },
++ { "multiple consecutive invalid utf-8 bytes", "SELECT '\xad\xae'", "SELECT '\\\\xad\\\\xae'", FALSE },
++
++ /* SQL metacharacters: quotes and semicolons are NOT escaped
++ * (unsafe_flags=0 — the escaping targets binary/control safety, not SQL) */
++ { "single quote passes through", "SELECT ''''", "", TRUE },
++ { "double quote passes through", "SELECT \"val\"", "", TRUE },
++ { "semicolon passes through", "SELECT 1; DROP TABLE users", "", TRUE },
++
++ /* DEL (0x7f) is >= 32 and not a backslash, so it passes through */
++ { "del character 0x7f passes through", "SELECT '\x7f'", "", TRUE },
++
++ /* valid multibyte UTF-8 must not be mangled */
++ { "valid utf-8 multibyte passes through", "SELECT 'árvíztűrőtükörfúrógép'", "", TRUE },
++ { "valid utf-8 followed by newline", "SELECT 'árvíztűrőtükörfúrógép\n'", "SELECT 'árvíztűrőtükörfúrógép\\n'", FALSE },
++
++ /* empty query edge case */
++ { "empty query string", "", "", TRUE },
++ };
++
++ return cr_make_param_array(QuerySanitizationParam, params,
++ sizeof(params) / sizeof(params[0]));
++}
++
++ParameterizedTest(QuerySanitizationParam *p, afsql_dd_run_query, query_sanitization)
++{
++ mock_dbi_reset();
++ mock_dbi_query_result = (dbi_result) 0x1;
++
++ afsql_dd_run_query(driver, p->raw_query, FALSE, NULL);
++
++ cr_assert_not_null(mock_dbi_last_query,
++ "[%s] Expected dbi_conn_query to have been called", p->description);
++
++ const gchar *expected = p->expected_query_is_null ? p->raw_query : p->expected_query;
++ cr_assert_str_eq(mock_dbi_last_query, expected,
++ "[%s] Sanitised query mismatch.\n got: %s\n expected: %s",
++ p->description, mock_dbi_last_query, expected);
++}
new file mode 100644
@@ -0,0 +1,758 @@
+From 4b61a0b1dad69368bc3b93b607141708d0036830 Mon Sep 17 00:00:00 2001
+From: =?UTF-8?q?B=C3=A1lint=20Horv=C3=A1th?= <bal.horv.98@gmail.com>
+Date: Tue, 7 Apr 2026 18:03:13 +0200
+Subject: [PATCH] afsql: Added tests against SQL injection
+MIME-Version: 1.0
+Content-Type: text/plain; charset=UTF-8
+Content-Transfer-Encoding: 8bit
+
+Signed-off-by: Hofi <hofione@gmail.com>
+Signed-off-by: Bálint Horváth <bal.horv.98@gmail.com>
+Signed-off-by: Hofi <hofione@gmail.com>
+
+CVE: CVE-2026-39879
+Upstream-Status: Backport [https://github.com/syslog-ng/syslog-ng/commit/4b61a0b1dad69368bc3b93b607141708d0036830]
+Signed-off-by: Peter Marko <peter.marko@siemens.com>
+---
+ modules/afsql/afsql.c | 2 +-
+ modules/afsql/tests/CMakeLists.txt | 7 +
+ modules/afsql/tests/Makefile.am | 25 +-
+ modules/afsql/tests/afsql_test_helpers.h | 87 ++++
+ modules/afsql/tests/mock-dbi.c | 29 +-
+ modules/afsql/tests/test_afsql.h | 1 +
+ .../tests/test_afsql_build_insert_command.c | 455 ++++++++++++++++++
+ modules/afsql/tests/test_afsql_run_query.c | 29 +-
+ 8 files changed, 597 insertions(+), 38 deletions(-)
+ create mode 100644 modules/afsql/tests/afsql_test_helpers.h
+ create mode 100644 modules/afsql/tests/test_afsql_build_insert_command.c
+
+diff --git a/modules/afsql/afsql.c b/modules/afsql/afsql.c
+index 2234acfe6..04b35e607 100644
+--- a/modules/afsql/afsql.c
++++ b/modules/afsql/afsql.c
+@@ -901,7 +901,7 @@ afsql_dd_append_value_to_be_inserted(AFSqlDestDriver *self,
+ return TRUE;
+ }
+
+-static GString *
++GString *
+ afsql_dd_build_insert_command(AFSqlDestDriver *self, LogMessage *msg, GString *table)
+ {
+ GString *insert_command = g_string_sized_new(256);
+diff --git a/modules/afsql/tests/CMakeLists.txt b/modules/afsql/tests/CMakeLists.txt
+index 12396f58f..c4ee592c1 100644
+--- a/modules/afsql/tests/CMakeLists.txt
++++ b/modules/afsql/tests/CMakeLists.txt
+@@ -6,4 +6,11 @@ if(ENABLE_SQL)
+ INCLUDES ${CMAKE_CURRENT_SOURCE_DIR}/.. ${LIBDBI_INCLUDE_DIRS}
+ DEPENDS OpenSSL::SSL
+ )
++ add_unit_test(
++ CRITERION LIBTEST
++ TARGET test_afsql_build_insert_command
++ SOURCES test_afsql_build_insert_command.c ../afsql.c mock-dbi.c
++ INCLUDES ${CMAKE_CURRENT_SOURCE_DIR}/.. ${LIBDBI_INCLUDE_DIRS}
++ DEPENDS OpenSSL::SSL
++ )
+ endif()
+diff --git a/modules/afsql/tests/Makefile.am b/modules/afsql/tests/Makefile.am
+index 96e0c1476..c7fc64fc1 100644
+--- a/modules/afsql/tests/Makefile.am
++++ b/modules/afsql/tests/Makefile.am
+@@ -1,11 +1,13 @@
+ if ENABLE_SQL
+
+ modules_afsql_tests_TESTS = \
+- modules/afsql/tests/test_afsql_run_query
++ modules/afsql/tests/test_afsql_run_query \
++ modules/afsql/tests/test_afsql_build_insert_command
+
+ check_PROGRAMS += ${modules_afsql_tests_TESTS}
+
+ modules_afsql_tests_test_afsql_run_query_SOURCES = \
++ modules/afsql/tests/afsql_test_helpers.h \
+ modules/afsql/tests/mock-dbi.h \
+ modules/afsql/tests/test_afsql.h \
+ modules/afsql/tests/test_afsql_run_query.c \
+@@ -25,6 +27,27 @@ modules_afsql_tests_test_afsql_run_query_LDADD = \
+ modules_afsql_tests_test_afsql_run_query_LDFLAGS = \
+ $(LDFLAGS)
+
++modules_afsql_tests_test_afsql_build_insert_command_SOURCES = \
++ modules/afsql/tests/afsql_test_helpers.h \
++ modules/afsql/tests/mock-dbi.h \
++ modules/afsql/tests/test_afsql.h \
++ modules/afsql/tests/test_afsql_build_insert_command.c \
++ modules/afsql/afsql.c \
++ modules/afsql/tests/mock-dbi.c
++
++modules_afsql_tests_test_afsql_build_insert_command_CFLAGS = \
++ $(TEST_CFLAGS) \
++ -I$(top_srcdir)/modules/afsql \
++ -I$(top_srcdir)/modules/afsql/tests \
++ $(LIBDBI_CFLAGS)
++
++modules_afsql_tests_test_afsql_build_insert_command_LDADD = \
++ $(TEST_LDADD) \
++ $(OPENSSL_LIBS)
++
++modules_afsql_tests_test_afsql_build_insert_command_LDFLAGS = \
++ $(LDFLAGS)
++
+ endif
+
+ EXTRA_DIST += modules/afsql/tests/CMakeLists.txt
+diff --git a/modules/afsql/tests/afsql_test_helpers.h b/modules/afsql/tests/afsql_test_helpers.h
+new file mode 100644
+index 000000000..9dabc763f
+--- /dev/null
++++ b/modules/afsql/tests/afsql_test_helpers.h
+@@ -0,0 +1,87 @@
++/*
++ * Copyright (c) 2026 One Identity LLC.
++ *
++ * This program is free software; you can redistribute it and/or modify it
++ * under the terms of the GNU General Public License version 2 as published
++ * by the Free Software Foundation, or (at your option) any later version.
++ *
++ * This program is distributed in the hope that it will be useful,
++ * but WITHOUT ANY WARRANTY; without even the implied warranty of
++ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
++ * GNU General Public License for more details.
++ *
++ * You should have received a copy of the GNU General Public License
++ * along with this program; if not, write to the Free Software
++ * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
++ *
++ * As an additional exemption you are allowed to compile & link against the
++ * OpenSSL libraries as published by the OpenSSL project. See the file
++ * COPYING for details.
++ *
++ */
++
++#ifndef AFSQL_TEST_HELPERS_H_INCLUDED
++#define AFSQL_TEST_HELPERS_H_INCLUDED
++
++#include "test_afsql.h"
++#include "mock-dbi.h"
++#include "cfg.h"
++#include "logmsg/logmsg.h"
++#include "template/templates.h"
++
++static inline AFSqlDestDriver *
++_create_driver(void)
++{
++ AFSqlDestDriver *self = g_new0(AFSqlDestDriver, 1);
++ self->type = g_strdup("mysql");
++ self->host = g_strdup("localhost");
++ self->port = g_strdup("3306");
++ self->user = g_strdup("testuser");
++ self->database = g_strdup("testdb");
++ self->quote_as_string = g_strdup("");
++ log_template_options_defaults(&self->template_options);
++ return self;
++}
++
++static inline void
++_free_driver(AFSqlDestDriver *self)
++{
++ for (gint i = 0; i < self->fields_len; i++)
++ {
++ g_free(self->fields[i].name);
++ g_free(self->fields[i].type);
++ log_template_unref(self->fields[i].value);
++ }
++ g_free(self->fields);
++ g_free(self->type);
++ g_free(self->host);
++ g_free(self->port);
++ g_free(self->user);
++ g_free(self->database);
++ g_free(self->quote_as_string);
++ g_free(self->null_value);
++ g_free(self);
++}
++
++static inline void
++_set_fields(AFSqlDestDriver *self, const gchar **col_names,
++ const gchar **templates, gint count)
++{
++ self->fields_len = count;
++ self->fields = g_new0(AFSqlField, count);
++ for (gint i = 0; i < count; i++)
++ {
++ self->fields[i].name = g_strdup(col_names[i]);
++ self->fields[i].type = g_strdup("text");
++ self->fields[i].value = log_template_new(configuration, NULL);
++ log_template_compile(self->fields[i].value, templates[i], NULL);
++ }
++}
++
++static inline GString *
++_make_table(const gchar *name)
++{
++ return g_string_new(name);
++}
++
++#endif /* AFSQL_TEST_HELPERS_H_INCLUDED */
+diff --git a/modules/afsql/tests/mock-dbi.c b/modules/afsql/tests/mock-dbi.c
+index af00dec07..7c1fca441 100644
+--- a/modules/afsql/tests/mock-dbi.c
++++ b/modules/afsql/tests/mock-dbi.c
+@@ -63,6 +63,27 @@ dbi_result_free(dbi_result result)
+ return 0;
+ }
+
++size_t
++dbi_conn_quote_string_copy(dbi_conn conn, const char *orig, char **dest)
++{
++ if (!orig || !dest)
++ return 0;
++
++ /* Minimal SQL quoting: wrap in single quotes, escape internal single quotes */
++ GString *quoted = g_string_new("'");
++ for (const char *s = orig; *s; s++)
++ {
++ if (*s == '\'')
++ g_string_append(quoted, "\\'");
++ else
++ g_string_append_c(quoted, *s);
++ }
++ g_string_append_c(quoted, '\'');
++ size_t quoted_len = quoted->len;
++ *dest = g_string_free(quoted, FALSE);
++ return quoted_len;
++}
++
+ /* --- no-op stubs for the rest of afsql.c --- */
+
+ dbi_conn
+@@ -100,14 +121,6 @@ dbi_conn_set_option_numeric(dbi_conn conn, const char *key, int value)
+ return 0;
+ }
+
+-size_t
+-dbi_conn_quote_string_copy(dbi_conn conn, const char *orig, char **dest)
+-{
+- if (dest)
+- *dest = NULL;
+- return 0;
+-}
+-
+ size_t
+ dbi_conn_quote_binary_copy(dbi_conn conn, const unsigned char *orig, size_t length, unsigned char **dest)
+ {
+diff --git a/modules/afsql/tests/test_afsql.h b/modules/afsql/tests/test_afsql.h
+index 804e815e3..7fdbb838d 100644
+--- a/modules/afsql/tests/test_afsql.h
++++ b/modules/afsql/tests/test_afsql.h
+@@ -27,5 +27,6 @@
+
+ gboolean afsql_dd_run_query(AFSqlDestDriver *self, const gchar *query, gboolean silent,
+ dbi_result *result);
++GString *afsql_dd_build_insert_command(AFSqlDestDriver *self, LogMessage *msg, GString *table);
+
+ #endif /* TEST_AFSQL_H_INCLUDED */
+diff --git a/modules/afsql/tests/test_afsql_build_insert_command.c b/modules/afsql/tests/test_afsql_build_insert_command.c
+new file mode 100644
+index 000000000..3fd1d3c00
+--- /dev/null
++++ b/modules/afsql/tests/test_afsql_build_insert_command.c
+@@ -0,0 +1,455 @@
++/*
++ * Copyright (c) 2026 One Identity LLC.
++ *
++ * This program is free software; you can redistribute it and/or modify it
++ * under the terms of the GNU General Public License version 2 as published
++ * by the Free Software Foundation, or (at your option) any later version.
++ *
++ * This program is distributed in the hope that it will be useful,
++ * but WITHOUT ANY WARRANTY; without even the implied warranty of
++ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
++ * GNU General Public License for more details.
++ *
++ * You should have received a copy of the GNU General Public License
++ * along with this program; if not, write to the Free Software
++ * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
++ *
++ * As an additional exemption you are allowed to compile & link against the
++ * OpenSSL libraries as published by the OpenSSL project. See the file
++ * COPYING for details.
++ *
++ */
++
++#include <criterion/criterion.h>
++#include <criterion/parameterized.h>
++
++#include "afsql_test_helpers.h"
++#include "apphook.h"
++
++/* ----------------------------- fixtures ----------------------------- */
++
++static AFSqlDestDriver *driver;
++
++static void
++setup(void)
++{
++ app_startup();
++ configuration = cfg_new_snippet();
++ driver = NULL;
++}
++
++static void
++teardown(void)
++{
++ if (driver)
++ _free_driver(driver);
++ cfg_free(configuration);
++ app_shutdown();
++}
++
++/* ===================================================================
++ * Suite 1: structural correctness
++ * =================================================================== */
++
++TestSuite(afsql_dd_build_insert_command, .init = setup, .fini = teardown);
++
++Test(afsql_dd_build_insert_command, basic_insert_structure)
++{
++ driver = _create_driver();
++ const gchar *cols[] = { "host", "message" };
++ const gchar *tmpls[] = { "myhost", "mymessage" };
++ _set_fields(driver, cols, tmpls, 2);
++
++ LogMessage *msg = log_msg_new_empty();
++ GString *table = _make_table("logs");
++ GString *cmd = afsql_dd_build_insert_command(driver, msg, table);
++
++ cr_assert_not_null(cmd);
++ cr_assert(g_str_has_prefix(cmd->str, "INSERT INTO logs ("),
++ "got: %s", cmd->str);
++ cr_assert(g_strstr_len(cmd->str, -1, ") VALUES (") != NULL,
++ "got: %s", cmd->str);
++ cr_assert(g_str_has_suffix(cmd->str, ")"),
++ "got: %s", cmd->str);
++
++ g_string_free(cmd, TRUE);
++ g_string_free(table, TRUE);
++ log_msg_unref(msg);
++}
++
++Test(afsql_dd_build_insert_command, column_names_appear_in_order)
++{
++ driver = _create_driver();
++ const gchar *cols[] = { "col_a", "col_b", "col_c" };
++ const gchar *tmpls[] = { "va", "vb", "vc" };
++ _set_fields(driver, cols, tmpls, 3);
++
++ LogMessage *msg = log_msg_new_empty();
++ GString *table = _make_table("t");
++ GString *cmd = afsql_dd_build_insert_command(driver, msg, table);
++
++ const gchar *a = g_strstr_len(cmd->str, -1, "col_a");
++ const gchar *b = g_strstr_len(cmd->str, -1, "col_b");
++ const gchar *c = g_strstr_len(cmd->str, -1, "col_c");
++ cr_assert(a && b && c && a < b && b < c,
++ "Expected columns in order col_a, col_b, col_c, got: %s", cmd->str);
++
++ g_string_free(cmd, TRUE);
++ g_string_free(table, TRUE);
++ log_msg_unref(msg);
++}
++
++Test(afsql_dd_build_insert_command, default_flagged_field_is_skipped)
++{
++ driver = _create_driver();
++ const gchar *cols[] = { "col_a", "col_b" };
++ const gchar *tmpls[] = { "va", "vb" };
++ _set_fields(driver, cols, tmpls, 2);
++ driver->fields[1].flags |= AFSQL_FF_DEFAULT;
++
++ LogMessage *msg = log_msg_new_empty();
++ GString *table = _make_table("t");
++ GString *cmd = afsql_dd_build_insert_command(driver, msg, table);
++
++ cr_assert(g_strstr_len(cmd->str, -1, "col_a") != NULL, "got: %s", cmd->str);
++ cr_assert(g_strstr_len(cmd->str, -1, "col_b") == NULL,
++ "Expected col_b absent (DEFAULT flag), got: %s", cmd->str);
++
++ g_string_free(cmd, TRUE);
++ g_string_free(table, TRUE);
++ log_msg_unref(msg);
++}
++
++Test(afsql_dd_build_insert_command, null_value_produces_NULL_keyword)
++{
++ driver = _create_driver();
++ driver->null_value = g_strdup("-");
++ const gchar *cols[] = { "msg" };
++ const gchar *tmpls[] = { "-" };
++ _set_fields(driver, cols, tmpls, 1);
++
++ LogMessage *msg = log_msg_new_empty();
++ GString *table = _make_table("t");
++ GString *cmd = afsql_dd_build_insert_command(driver, msg, table);
++
++ cr_assert(g_strstr_len(cmd->str, -1, "NULL") != NULL,
++ "Expected NULL keyword for null_value match, got: %s", cmd->str);
++
++ g_string_free(cmd, TRUE);
++ g_string_free(table, TRUE);
++ log_msg_unref(msg);
++}
++
++/*
++ * Typed values: LM_VT_NULL, LM_VT_INTEGER and LM_VT_BOOLEAN must emit
++ * unquoted SQL keywords/literals — never a quoted string.
++ */
++typedef struct
++{
++ gchar description[64];
++ gchar raw_value[32];
++ LogMessageValueType type;
++ gchar must_contain[16]; /* expected unquoted form */
++ gchar must_not_contain[16]; /* must not be quoted */
++} TypedValueParam;
++
++ParameterizedTestParameters(afsql_dd_build_insert_command, typed_value_is_not_quoted)
++{
++ static TypedValueParam params[] =
++ {
++ { "LM_VT_NULL emits bare NULL", "", LM_VT_NULL, "NULL", "'NULL'" },
++ { "LM_VT_INTEGER emits bare integer", "42", LM_VT_INTEGER, "42", "'42'" },
++ { "LM_VT_BOOLEAN true emits TRUE", "true", LM_VT_BOOLEAN, "TRUE", "'TRUE'" },
++ { "LM_VT_BOOLEAN false emits FALSE", "false", LM_VT_BOOLEAN, "FALSE", "'FALSE'"},
++ };
++ return cr_make_param_array(TypedValueParam, params,
++ sizeof(params) / sizeof(params[0]));
++}
++
++ParameterizedTest(TypedValueParam *p, afsql_dd_build_insert_command, typed_value_is_not_quoted)
++{
++ driver = _create_driver();
++ const gchar *cols[] = { "col" };
++ const gchar *tmpls[] = { "${VAL}" };
++ _set_fields(driver, cols, tmpls, 1);
++
++ LogMessage *msg = log_msg_new_empty();
++ NVHandle handle = log_msg_get_value_handle("VAL");
++ log_msg_set_value_with_type(msg, handle, p->raw_value, -1, p->type);
++ GString *table = _make_table("t");
++ GString *cmd = afsql_dd_build_insert_command(driver, msg, table);
++
++ cr_assert(g_strstr_len(cmd->str, -1, p->must_contain) != NULL,
++ "[%s] Expected '%s' in command, got: %s",
++ p->description, p->must_contain, cmd->str);
++ cr_assert(g_strstr_len(cmd->str, -1, p->must_not_contain) == NULL,
++ "[%s] Expected '%s' absent (value must not be quoted), got: %s",
++ p->description, p->must_not_contain, cmd->str);
++
++ g_string_free(cmd, TRUE);
++ g_string_free(table, TRUE);
++ log_msg_unref(msg);
++}
++
++/*
++ * Comma placement: the function looks ahead to skip DEFAULT fields when
++ * deciding whether to append ", ". These tests verify no spurious leading,
++ * trailing, or doubled commas appear.
++ */
++typedef struct
++{
++ gchar description[64];
++ gint default_field_idx; /* which of 3 fields gets AFSQL_FF_DEFAULT */
++ gchar must_contain[32];
++ gchar must_not_contain[32];
++} CommaPlacementParam;
++
++ParameterizedTestParameters(afsql_dd_build_insert_command, comma_placement)
++{
++ static CommaPlacementParam params[] =
++ {
++ { "no trailing comma when last field is DEFAULT", 2, "col_a, col_b", "col_b," },
++ { "no leading comma when first field is DEFAULT", 0, "col_b, col_c", "" },
++ { "comma bridges over DEFAULT middle field", 1, "col_a, col_c", "col_b" },
++ };
++ return cr_make_param_array(CommaPlacementParam, params,
++ sizeof(params) / sizeof(params[0]));
++}
++
++ParameterizedTest(CommaPlacementParam *p, afsql_dd_build_insert_command, comma_placement)
++{
++ driver = _create_driver();
++ const gchar *cols[] = { "col_a", "col_b", "col_c" };
++ const gchar *tmpls[] = { "va", "vb", "vc" };
++ _set_fields(driver, cols, tmpls, 3);
++ driver->fields[p->default_field_idx].flags |= AFSQL_FF_DEFAULT;
++
++ LogMessage *msg = log_msg_new_empty();
++ GString *table = _make_table("t");
++ GString *cmd = afsql_dd_build_insert_command(driver, msg, table);
++
++ cr_assert(g_strstr_len(cmd->str, -1, p->must_contain) != NULL,
++ "[%s] Expected '%s' in command, got: %s",
++ p->description, p->must_contain, cmd->str);
++ if (p->must_not_contain[0])
++ cr_assert(g_strstr_len(cmd->str, -1, p->must_not_contain) == NULL,
++ "[%s] Expected '%s' absent from command, got: %s",
++ p->description, p->must_not_contain, cmd->str);
++
++ g_string_free(cmd, TRUE);
++ g_string_free(table, TRUE);
++ log_msg_unref(msg);
++}
++
++/* ===================================================================
++ * Suite 2: parameterized — table name and quote_as_string
++ * =================================================================== */
++
++TestSuite(table_name_in_command, .init = setup, .fini = teardown);
++
++typedef struct
++{
++ gchar description[64];
++ gchar table_name[64];
++ gchar quote_char[4]; /* quote_as_string value */
++ gchar expected_prefix[64];
++} TableNameParam;
++
++ParameterizedTestParameters(table_name_in_command, variants)
++{
++ static TableNameParam params[] =
++ {
++ { "no quoting", "logs", "", "INSERT INTO logs (" },
++ { "backtick quoting", "logs", "`", "INSERT INTO `logs` (" },
++ { "double-quote quoting", "logs", "\"", "INSERT INTO \"logs\" ("},
++ { "table with underscore", "log_data", "", "INSERT INTO log_data ("},
++ { "table with dot (schema)", "db.logs", "", "INSERT INTO db.logs ("},
++ };
++ return cr_make_param_array(TableNameParam, params,
++ sizeof(params) / sizeof(params[0]));
++}
++
++ParameterizedTest(TableNameParam *p, table_name_in_command, variants)
++{
++ driver = _create_driver();
++ g_free(driver->quote_as_string);
++ driver->quote_as_string = g_strdup(p->quote_char);
++ const gchar *cols[] = { "col" };
++ const gchar *tmpls[] = { "v" };
++ _set_fields(driver, cols, tmpls, 1);
++
++ LogMessage *msg = log_msg_new_empty();
++ GString *table = _make_table(p->table_name);
++ GString *cmd = afsql_dd_build_insert_command(driver, msg, table);
++
++ cr_assert(g_str_has_prefix(cmd->str, p->expected_prefix),
++ "[%s] Expected prefix '%s', got: %s",
++ p->description, p->expected_prefix, cmd->str);
++
++ g_string_free(cmd, TRUE);
++ g_string_free(table, TRUE);
++ log_msg_unref(msg);
++}
++
++/* ===================================================================
++ * Suite 3: parameterized — value rendering in VALUES clause
++ * =================================================================== */
++
++TestSuite(value_rendering, .init = setup, .fini = teardown);
++
++typedef struct
++{
++ gchar description[64];
++ gchar tmpl[32]; /* template string for the single field */
++ gchar msg_key[32]; /* empty string means don't set a message key */
++ gchar msg_value[64]; /* message value */
++ gchar expected[64]; /* substring expected in the full command */
++} ValueRenderingParam;
++
++ParameterizedTestParameters(value_rendering, variants)
++{
++ static ValueRenderingParam params[] =
++ {
++ { "literal value is quoted", "hello", "", "", "'hello'" },
++ { "empty string is quoted", "", "", "", "''" },
++ { "template expands from message", "${HOST}", "HOST", "myserver", "'myserver'" },
++ { "spaces in value", "hello world", "", "", "'hello world'"},
++ };
++ return cr_make_param_array(ValueRenderingParam, params,
++ sizeof(params) / sizeof(params[0]));
++}
++
++ParameterizedTest(ValueRenderingParam *p, value_rendering, variants)
++{
++ driver = _create_driver();
++ const gchar *cols[] = { "col" };
++ const gchar *tmpls[] = { p->tmpl };
++ _set_fields(driver, cols, tmpls, 1);
++
++ LogMessage *msg = log_msg_new_empty();
++ if (p->msg_key[0])
++ log_msg_set_value_by_name(msg, p->msg_key, p->msg_value, -1);
++
++ GString *table = _make_table("t");
++ GString *cmd = afsql_dd_build_insert_command(driver, msg, table);
++
++ cr_assert(g_strstr_len(cmd->str, -1, p->expected) != NULL,
++ "[%s] Expected '%s' in command, got: %s",
++ p->description, p->expected, cmd->str);
++
++ g_string_free(cmd, TRUE);
++ g_string_free(table, TRUE);
++ log_msg_unref(msg);
++}
++
++/* ===================================================================
++ * Suite 4: SQL injection — values are safe, table name is not
++ *
++ * Values are rendered via dbi_conn_quote_string_copy, so injection
++ * payloads in message fields cannot break out of the quoted string.
++ *
++ * Table names are currently inserted bare (wrapped only with
++ * quote_as_string). A table name derived from a log message template
++ * can therefore inject arbitrary SQL — this suite documents that gap.
++ * =================================================================== */
++
++TestSuite(sql_injection, .init = setup, .fini = teardown);
++
++typedef struct
++{
++ gchar description[64];
++ gchar msg_value[64]; /* attacker-controlled field value */
++ gchar must_not_contain[64]; /* raw payload must not appear bare */
++ gchar must_contain[64]; /* safely-quoted form must appear */
++} ValueInjectionParam;
++
++ParameterizedTestParameters(sql_injection, value_is_safe)
++{
++ static ValueInjectionParam params[] =
++ {
++ { "single quote cannot break out", "'; DROP TABLE users; --", "'; DROP TABLE users; --", "\\'; DROP TABLE users; --" },
++ { "multiple single quotes are all escaped", "it's a ''test''", "", "it\\'s a \\'\\'" },
++ { "closing paren + VALUES pattern in value", "') VALUES ('evil", "", "\\') VALUES (\\'evil" },
++ };
++ return cr_make_param_array(ValueInjectionParam, params,
++ sizeof(params) / sizeof(params[0]));
++}
++
++ParameterizedTest(ValueInjectionParam *p, sql_injection, value_is_safe)
++{
++ driver = _create_driver();
++ const gchar *cols[] = { "msg" };
++ const gchar *tmpls[] = { "${MSG}" };
++ _set_fields(driver, cols, tmpls, 1);
++
++ LogMessage *msg = log_msg_new_empty();
++ log_msg_set_value_by_name(msg, "MSG", p->msg_value, -1);
++
++ GString *table = _make_table("t");
++ GString *cmd = afsql_dd_build_insert_command(driver, msg, table);
++
++ if (p->must_not_contain[0])
++ {
++ gchar *raw = g_strdup_printf("'%s'", p->msg_value);
++ cr_assert(g_strstr_len(cmd->str, -1, raw) == NULL,
++ "[%s] Raw unescaped payload found in command: %s",
++ p->description, cmd->str);
++ g_free(raw);
++ }
++
++ cr_assert(g_strstr_len(cmd->str, -1, p->must_contain) != NULL,
++ "[%s] Expected escaped form '%s' in command, got: %s",
++ p->description, p->must_contain, cmd->str);
++
++ g_string_free(cmd, TRUE);
++ g_string_free(table, TRUE);
++ log_msg_unref(msg);
++}
++
++/*
++ * Table name injection: documents that a table name derived from a log
++ * message template is inserted bare into the SQL command. This is the
++ * injection surface identified in the code review.
++ */
++typedef struct
++{
++ gchar description[64];
++ gchar table_payload[80];
++} TableInjectionParam;
++
++ParameterizedTestParameters(sql_injection, table_name_is_not_escaped)
++{
++ static TableInjectionParam params[] =
++ {
++ { "semicolon + DROP TABLE", "logs; DROP TABLE users; --" },
++ { "single quote breaks out", "logs' WHERE 1=1 --" },
++ { "UNION SELECT via table name", "t UNION SELECT * FROM secrets --" },
++ };
++ return cr_make_param_array(TableInjectionParam, params,
++ sizeof(params) / sizeof(params[0]));
++}
++
++ParameterizedTest(TableInjectionParam *p, sql_injection, table_name_is_not_escaped)
++{
++ driver = _create_driver();
++ const gchar *cols[] = { "col" };
++ const gchar *tmpls[] = { "v" };
++ _set_fields(driver, cols, tmpls, 1);
++
++ LogMessage *msg = log_msg_new_empty();
++ GString *table = _make_table(p->table_payload);
++ GString *cmd = afsql_dd_build_insert_command(driver, msg, table);
++
++ /*
++ * Documents the current (unsafe) behaviour: the payload appears verbatim.
++ * Once table-name escaping is implemented these assertions should be
++ * inverted.
++ */
++ cr_assert(g_strstr_len(cmd->str, -1, p->table_payload) != NULL,
++ "[%s] Expected bare payload in command (documents unescaped table name), got: %s",
++ p->description, cmd->str);
++
++ g_string_free(cmd, TRUE);
++ g_string_free(table, TRUE);
++ log_msg_unref(msg);
++}
+diff --git a/modules/afsql/tests/test_afsql_run_query.c b/modules/afsql/tests/test_afsql_run_query.c
+index b26f631a7..a94e48538 100644
+--- a/modules/afsql/tests/test_afsql_run_query.c
++++ b/modules/afsql/tests/test_afsql_run_query.c
+@@ -23,36 +23,9 @@
+ #include <criterion/criterion.h>
+ #include <criterion/parameterized.h>
+
+-#include "test_afsql.h"
+-#include "mock-dbi.h"
++#include "afsql_test_helpers.h"
+ #include "apphook.h"
+
+-/* ----------------------------- helpers ----------------------------- */
+-
+-static AFSqlDestDriver *
+-_create_driver(void)
+-{
+- AFSqlDestDriver *self = g_new0(AFSqlDestDriver, 1);
+- self->type = g_strdup("mysql");
+- self->host = g_strdup("localhost");
+- self->port = g_strdup("3306");
+- self->user = g_strdup("testuser");
+- self->database = g_strdup("testdb");
+- /* dbi_ctx is intentionally left NULL; mock-dbi ignores the conn handle */
+- return self;
+-}
+-
+-static void
+-_free_driver(AFSqlDestDriver *self)
+-{
+- g_free(self->type);
+- g_free(self->host);
+- g_free(self->port);
+- g_free(self->user);
+- g_free(self->database);
+- g_free(self);
+-}
+-
+ /* ----------------------------- fixtures ----------------------------- */
+
+ static AFSqlDestDriver *driver;
new file mode 100644
@@ -0,0 +1,176 @@
+From 4c0aebf7a1c0a8177bdf4fb67803e19dddc3dbf2 Mon Sep 17 00:00:00 2001
+From: =?UTF-8?q?B=C3=A1lint=20Horv=C3=A1th?= <bal.horv.98@gmail.com>
+Date: Mon, 20 Apr 2026 16:37:04 +0200
+Subject: [PATCH] afsql: Sanitized table name to fix possible injection
+MIME-Version: 1.0
+Content-Type: text/plain; charset=UTF-8
+Content-Transfer-Encoding: 8bit
+
+The DONT_CREATE_TABLES early return in
+afsql_dd_ensure_table_is_syslogng_conform() bypasses
+_sanitize_sql_identifier(), leaving the table name unsanitized when
+the schema helper is skipped.
+
+Signed-off-by: Bálint Horváth <bal.horv.98@gmail.com>
+Signed-off-by: Hofi <hofione@gmail.com>
+
+CVE: CVE-2026-39879
+Upstream-Status: Backport [https://github.com/syslog-ng/syslog-ng/commit/4c0aebf7a1c0a8177bdf4fb67803e19dddc3dbf2]
+Signed-off-by: Peter Marko <peter.marko@siemens.com>
+---
+ modules/afsql/afsql.c | 3 +-
+ modules/afsql/tests/afsql_test_helpers.h | 1 +
+ modules/afsql/tests/test_afsql.h | 3 +-
+ .../tests/test_afsql_build_insert_command.c | 71 +++++++++++++++----
+ 4 files changed, 62 insertions(+), 16 deletions(-)
+
+diff --git a/modules/afsql/afsql.c b/modules/afsql/afsql.c
+index 04b35e607..3f5032ab8 100644
+--- a/modules/afsql/afsql.c
++++ b/modules/afsql/afsql.c
+@@ -775,13 +775,14 @@ afsql_dd_disconnect(LogThreadedDestDriver *s)
+ self->dbi_ctx = NULL;
+ }
+
+-static GString *
++GString *
+ afsql_dd_ensure_accessible_database_table(AFSqlDestDriver *self, LogMessage *msg)
+ {
+ GString *table = g_string_sized_new(32);
+
+ LogTemplateEvalOptions options = {&self->template_options, LTZ_LOCAL, 0, NULL, LM_VT_STRING};
+ log_template_format(self->table, msg, &options, table);
++ _sanitize_sql_identifier(table->str);
+
+ if (!afsql_dd_ensure_table_is_syslogng_conform(self, table))
+ {
+diff --git a/modules/afsql/tests/afsql_test_helpers.h b/modules/afsql/tests/afsql_test_helpers.h
+index 9dabc763f..ba5f07763 100644
+--- a/modules/afsql/tests/afsql_test_helpers.h
++++ b/modules/afsql/tests/afsql_test_helpers.h
+@@ -60,6 +60,7 @@ _free_driver(AFSqlDestDriver *self)
+ g_free(self->database);
+ g_free(self->quote_as_string);
+ g_free(self->null_value);
++ log_template_unref(self->table);
+ g_free(self);
+ }
+
+diff --git a/modules/afsql/tests/test_afsql.h b/modules/afsql/tests/test_afsql.h
+index 7fdbb838d..3195c2156 100644
+--- a/modules/afsql/tests/test_afsql.h
++++ b/modules/afsql/tests/test_afsql.h
+@@ -26,7 +26,8 @@
+ #include "afsql.h"
+
+ gboolean afsql_dd_run_query(AFSqlDestDriver *self, const gchar *query, gboolean silent,
+- dbi_result *result);
++ dbi_result *result);
+ GString *afsql_dd_build_insert_command(AFSqlDestDriver *self, LogMessage *msg, GString *table);
++GString *afsql_dd_ensure_accessible_database_table(AFSqlDestDriver *self, LogMessage *msg);
+
+ #endif /* TEST_AFSQL_H_INCLUDED */
+diff --git a/modules/afsql/tests/test_afsql_build_insert_command.c b/modules/afsql/tests/test_afsql_build_insert_command.c
+index 3fd1d3c00..130c6d54e 100644
+--- a/modules/afsql/tests/test_afsql_build_insert_command.c
++++ b/modules/afsql/tests/test_afsql_build_insert_command.c
+@@ -407,23 +407,24 @@ ParameterizedTest(ValueInjectionParam *p, sql_injection, value_is_safe)
+ }
+
+ /*
+- * Table name injection: documents that a table name derived from a log
+- * message template is inserted bare into the SQL command. This is the
+- * injection surface identified in the code review.
++ * Table name injection: verifies that hostile characters in a table name
++ * derived from a log-message template are replaced with underscores before
++ * the name is used in any SQL statement.
+ */
+ typedef struct
+ {
+ gchar description[64];
+ gchar table_payload[80];
++ gchar sanitized_form[80];
+ } TableInjectionParam;
+
+ ParameterizedTestParameters(sql_injection, table_name_is_not_escaped)
+ {
+ static TableInjectionParam params[] =
+ {
+- { "semicolon + DROP TABLE", "logs; DROP TABLE users; --" },
+- { "single quote breaks out", "logs' WHERE 1=1 --" },
+- { "UNION SELECT via table name", "t UNION SELECT * FROM secrets --" },
++ { "semicolon + DROP TABLE", "logs; DROP TABLE users; --", "logs__DROP_TABLE_users____" },
++ { "single quote breaks out", "logs' WHERE 1=1 --", "logs__WHERE_1_1___" },
++ { "UNION SELECT via table name", "t UNION SELECT * FROM secrets --", "t_UNION_SELECT___FROM_secrets___" },
+ };
+ return cr_make_param_array(TableInjectionParam, params,
+ sizeof(params) / sizeof(params[0]));
+@@ -437,18 +438,60 @@ ParameterizedTest(TableInjectionParam *p, sql_injection, table_name_is_not_escap
+ _set_fields(driver, cols, tmpls, 1);
+
+ LogMessage *msg = log_msg_new_empty();
+- GString *table = _make_table(p->table_payload);
++ GString *table = _make_table(p->sanitized_form);
+ GString *cmd = afsql_dd_build_insert_command(driver, msg, table);
+
+- /*
+- * Documents the current (unsafe) behaviour: the payload appears verbatim.
+- * Once table-name escaping is implemented these assertions should be
+- * inverted.
+- */
+- cr_assert(g_strstr_len(cmd->str, -1, p->table_payload) != NULL,
+- "[%s] Expected bare payload in command (documents unescaped table name), got: %s",
++ /* Raw hostile payload must NOT appear verbatim in the SQL command. */
++ cr_assert(g_strstr_len(cmd->str, -1, p->table_payload) == NULL,
++ "[%s] Hostile payload found verbatim in command: %s",
+ p->description, cmd->str);
+
++ /* Sanitized (underscore-replaced) form MUST appear. */
++ cr_assert(g_strstr_len(cmd->str, -1, p->sanitized_form) != NULL,
++ "[%s] Expected sanitized form '%s' in command, got: %s",
++ p->description, p->sanitized_form, cmd->str);
++
++ g_string_free(cmd, TRUE);
++ g_string_free(table, TRUE);
++ log_msg_unref(msg);
++}
++
++/*
++ * When AFSQL_DDF_DONT_CREATE_TABLES is set, afsql_dd_ensure_table_is_syslogng_conform
++ * returns TRUE immediately, skipping its own _sanitize_sql_identifier call. The only
++ * sanitization is then the line added by the GHSA-qwf9-6222-m24m fix inside
++ * afsql_dd_ensure_accessible_database_table, immediately after log_template_format.
++ */
++Test(sql_injection, dont_create_tables_flag_sanitizes_table_name)
++{
++ driver = _create_driver();
++ driver->super.flags |= AFSQL_DDF_DONT_CREATE_TABLES;
++
++ driver->table = log_template_new(configuration, NULL);
++ log_template_compile_literal_string(driver->table, "logs;evil'table (name)");
++
++ const gchar *cols[] = { "col" };
++ const gchar *tmpls[] = { "v" };
++ _set_fields(driver, cols, tmpls, 1);
++
++ LogMessage *msg = log_msg_new_empty();
++
++ GString *table = afsql_dd_ensure_accessible_database_table(driver, msg);
++ cr_assert_not_null(table, "ensure_accessible_database_table returned NULL unexpectedly");
++
++ cr_assert(g_strstr_len(table->str, -1, ";") == NULL,
++ "Semicolon survived sanitization: %s", table->str);
++ cr_assert(g_strstr_len(table->str, -1, "'") == NULL,
++ "Single quote survived sanitization: %s", table->str);
++ cr_assert(g_strstr_len(table->str, -1, "(") == NULL,
++ "Opening paren survived sanitization: %s", table->str);
++ cr_assert(g_strstr_len(table->str, -1, " ") == NULL,
++ "Space survived sanitization: %s", table->str);
++
++ GString *cmd = afsql_dd_build_insert_command(driver, msg, table);
++ cr_assert(g_strstr_len(cmd->str, -1, "logs_evil_table__name_") != NULL,
++ "Sanitized table name not found in INSERT command: %s", cmd->str);
++
+ g_string_free(cmd, TRUE);
+ g_string_free(table, TRUE);
+ log_msg_unref(msg);
@@ -25,6 +25,10 @@ SRC_URI = "https://github.com/balabit/syslog-ng/releases/download/${BP}/${BP}.ta
file://0001-Fix-buildpaths-warning.patch \
file://0001-macros-guard-ipv6-code-with-SYSLOG_NG_ENABLE_IPV6.patch \
file://CVE-2024-47619.patch \
+ file://CVE-2026-39879-01.patch \
+ file://CVE-2026-39879-02.patch \
+ file://CVE-2026-39879-03.patch \
+ file://CVE-2026-39879-04.patch \
"
SRC_URI:append:powerpc64le = " file://0001-plugin.c-workaround-powerpc64le-segfaults-error.patch"