Hi,

Both CVEs in this report were fixed upstream on 2026-10-31. Neither fix is
in a release yet (newest tag is v1.7.2, and both advisories read "through
1.7.2").

CVE-2026-118692 (OS command injection via the custom web-link action,
upstream issue #2945) is fixed by commit 0a0638651f31. Instead of
fmt::sprintf(template, url) followed by std::system(), the fix parses the
template with Glib::shell_parse_argv(), substitutes the URL into individual
argv elements, and runs it with Glib::spawn_async() (no shell). The same
change also covers the file-link and folder-link custom actions, which
share the std::system sink.

CVE-2026-118693 (arbitrary local file read via a crafted embedded-file
name, upstream issue #2944) is fixed by commit 61fca359a431, which
validates the embedded-file "filename" attribute instead of trusting it.

Affected shipped versions (verified in the source): bookworm 0.99.48 and
trixie/sid/forky 1.2.0 both carry the vulnerable code (link_clicked
sprintf+system; open_filepath/open_folderpath sprintf+system; and
_create_image_from_xml reading "filename").

Attached are backports of the CVE-2026-118692 fix, adapted to the shipped
versions (the upstream commit does not cherry-pick: it was refactored into
ct_filesystem.cc, while the shipped versions have the call inline):

CVE-2026-118692-cherrytree-1.2.0.patch trixie / sid / forky (1.2.0+dfsg-1)
CVE-2026-118692-cherrytree-0.99.48.patch bookworm (0.99.48+dfsg-1)

Both are DEP-3 and touch three files (ct_actions_others.cc,
ct_filesystem.cc, ct_filesystem.h). They route all three custom-action
sinks (web, file, folder link) through a new helper that parses the trusted
template with Glib::shell_parse_argv, substitutes the untrusted argument
into argv elements, and spawns with Glib::spawn_async -- no shell. The
existing default templates keep working: the helper drops the historical
trailing "&". ct_config.h is left unchanged.

Verification:
- Both patches apply cleanly to the packaged sources (patch -p1).
- I unit-tested the ported helper in isolation against the real
g_shell_parse_argv: an argument containing $(...), backticks or ';'
stays a single argv element for every template shape tested; and on a
live payload, the old std::system path runs the injected command while
the new argv+spawn path does not.
- I did not rebuild the full GTK application; the change uses only
Glib/spdlog calls already present in these files.

I can provide a backport for CVE-2026-118693 as well if that would help.

Regards,
Sumit
Description: Prevent OS command injection via custom link actions (CVE-2026-118692)
 CtActions::link_clicked (web link custom action) and fs::open_filepath /
 fs::open_folderpath (file / folder link custom actions) built a shell
 command with fmt::sprintf(customAct, arg) and ran it through std::system(),
 so a web-link URL or a file/folder path containing $(...), backticks or ';'
 was interpreted by the shell. When the non-default Custom Link Action option
 is enabled, a crafted document could thus run arbitrary commands on click.
 .
 Backport of upstream commit 0a0638651f31 (not yet in a release). The trusted
 command template is parsed with Glib::shell_parse_argv(), the untrusted
 argument is substituted into individual argv elements (or appended when the
 template has no %s), and the program is run with Glib::spawn_async() without
 a shell, so shell metacharacters in the argument are never interpreted. The
 trailing "&" historically used in the default templates for backgrounding is
 dropped (spawn_async is already asynchronous), so the existing default
 templates keep working unchanged.
Author: Sumit Verma <[email protected]>
Origin: backport, https://github.com/giuspen/cherrytree/commit/0a0638651f31
Bug-Debian: https://bugs.debian.org/1152740
Forwarded: not-needed (fixed upstream)
Last-Update: 2026-11-06
---
--- a/src/ct/ct_actions_others.cc
+++ b/src/ct/ct_actions_others.cc
@@ -333,13 +333,7 @@
     if (link_entry.type == CtConst::LINK_TYPE_WEBS) { // link to webpage
         Glib::ustring clean_weblink = str::replace(link_entry.webs, "amp;", "");
         if (_pCtConfig->weblinkCustomOn) {
-            std::string cmd = fmt::sprintf(_pCtConfig->weblinkCustomAct, clean_weblink.raw());
-            int retr = std::system(cmd.c_str());
-            if (retr == -1) {
-                // Internal std::system error
-                spdlog::error("Error while executing: '{}'; Message: {}", cmd, std::strerror(errno));
-                return;
-            }
+            fs::open_custom_action(_pCtConfig->weblinkCustomAct, clean_weblink.raw());
         }
         else {
             fs::open_weblink(clean_weblink);
--- a/src/ct/ct_filesystem.cc
+++ b/src/ct/ct_filesystem.cc
@@ -22,6 +22,8 @@
  */
 
 #include <glibmm/miscutils.h>
+#include <glibmm/shell.h>
+#include <glibmm/spawn.h>
 #include <giomm/file.h>
 #include <glib/gstdio.h>
 #include <curl/curl.h>
@@ -315,18 +317,76 @@
     _locale_env_vars_set_for_external_cmd(false/*isPre*/);
 }
 
+static std::vector<std::string> custom_action_build_argv(const std::string& template_cmd, const std::string& target_arg)
+{
+    if (template_cmd.empty()) {
+        return {};
+    }
+    std::vector<std::string> argv;
+    try {
+        argv = Glib::shell_parse_argv(template_cmd);
+    }
+    catch (const Glib::Error& error) {
+        spdlog::error("custom_action_build_argv: failed to parse template '{}': {}", template_cmd, error.what().raw());
+        return {};
+    }
+    if (argv.empty()) {
+        return {};
+    }
+
+    // Strip trailing "&" historically used for shell backgrounding
+    if (argv.back() == "&") {
+        argv.pop_back();
+    }
+    if (argv.empty()) {
+        return {};
+    }
+
+    bool substituted = false;
+    for (auto& arg : argv) {
+        size_t pos = 0;
+        while ((pos = arg.find("%s", pos)) != std::string::npos) {
+            arg.replace(pos, 2, target_arg);
+            pos += target_arg.length();
+            substituted = true;
+        }
+    }
+    if (!substituted) {
+        argv.push_back(target_arg);
+    }
+    return argv;
+}
+
+bool open_custom_action(const std::string& template_cmd, const std::string& target_arg)
+{
+    const std::vector<std::string> argv = custom_action_build_argv(template_cmd, target_arg);
+    if (argv.empty()) {
+        return false;
+    }
+
+    _locale_env_vars_set_for_external_cmd(true/*isPre*/);
+    bool spawned = false;
+    try {
+#if GTKMM_MAJOR_VERSION >= 4
+        Glib::spawn_async("", argv, Glib::SpawnFlags::SEARCH_PATH);
+#else
+        Glib::spawn_async("", argv, Glib::SpawnFlags::SPAWN_SEARCH_PATH);
+#endif
+        spawned = true;
+    }
+    catch (const Glib::Error& error) {
+        spdlog::error("open_custom_action: failed to spawn '{}': {}", argv.front(), error.what().raw());
+    }
+    _locale_env_vars_set_for_external_cmd(false/*isPre*/);
+    return spawned;
+}
+
 // Open Filepath with External App
 void open_filepath(const fs::path& filepath, bool open_folder_if_file_not_exists, CtConfig* config)
 {
     spdlog::debug("fs::open_filepath {}", filepath.string());
     if (config->filelinkCustomOn) {
-        std::string cmd = fmt::sprintf(config->filelinkCustomAct, filepath.string());
-        _locale_env_vars_set_for_external_cmd(true/*isPre*/);
-        const int retVal = std::system(cmd.c_str());
-        _locale_env_vars_set_for_external_cmd(false/*isPre*/);
-        if (retVal != 0) {
-            spdlog::error("system({}) returned {}", cmd, retVal);
-        }
+        open_custom_action(config->filelinkCustomAct, filepath.string());
     }
     else {
         if (open_folder_if_file_not_exists && !fs::exists(filepath)) {
@@ -347,13 +407,7 @@
 {
     spdlog::debug("fs::open_folderpath {}", folderpath.string());
     if (config->folderlinkCustomOn) {
-        std::string cmd = fmt::sprintf(config->folderlinkCustomAct, folderpath.string());
-        _locale_env_vars_set_for_external_cmd(true/*isPre*/);
-        const int retVal = std::system(cmd.c_str());
-        _locale_env_vars_set_for_external_cmd(false/*isPre*/);
-        if (retVal != 0) {
-            spdlog::error("system({}) returned {}", cmd, retVal);
-        }
+        open_custom_action(config->folderlinkCustomAct, folderpath.string());
     }
     else {
         _open_path_with_default_app(folderpath);
--- a/src/ct/ct_filesystem.h
+++ b/src/ct/ct_filesystem.h
@@ -82,6 +82,7 @@
 void open_weblink(const std::string& link);
 void open_filepath(const path& filepath, bool open_folder_if_file_not_exists, CtConfig* config);
 void open_folderpath(const path& folderpath, CtConfig* config);
+bool open_custom_action(const std::string& template_cmd, const std::string& target_arg);
 
 path prepare_export_folder(const path& dir_place, path new_folder, bool overwrite_existing);
 
Description: Prevent OS command injection via custom link actions (CVE-2026-118692)
 CtActions::link_clicked (web link custom action) and fs::open_filepath /
 fs::open_folderpath (file / folder link custom actions) built a shell
 command with fmt::sprintf(customAct, arg) and ran it through std::system(),
 so a web-link URL or a file/folder path containing $(...), backticks or ';'
 was interpreted by the shell. When the non-default Custom Link Action option
 is enabled, a crafted document could thus run arbitrary commands on click.
 .
 Backport of upstream commit 0a0638651f31 (not yet in a release). The trusted
 command template is parsed with Glib::shell_parse_argv(), the untrusted
 argument is substituted into individual argv elements (or appended when the
 template has no %s), and the program is run with Glib::spawn_async() without
 a shell, so shell metacharacters in the argument are never interpreted. The
 trailing "&" historically used in the default templates for backgrounding is
 dropped (spawn_async is already asynchronous), so the existing default
 templates keep working unchanged.
Author: Sumit Verma <[email protected]>
Origin: backport, https://github.com/giuspen/cherrytree/commit/0a0638651f31
Bug-Debian: https://bugs.debian.org/1152740
Forwarded: not-needed (fixed upstream)
Last-Update: 2026-11-06
---
--- a/src/ct/ct_actions_others.cc
+++ b/src/ct/ct_actions_others.cc
@@ -344,13 +344,7 @@
     if (link_entry.type == CtConst::LINK_TYPE_WEBS) { // link to webpage
         Glib::ustring clean_weblink = str::replace(link_entry.webs, "amp;", "");
         if (_pCtConfig->weblinkCustomOn) {
-            std::string cmd = fmt::sprintf(_pCtConfig->weblinkCustomAct, clean_weblink.raw());
-            int retr = std::system(cmd.c_str());
-            if (retr == -1) {
-                // Internal std::system error
-                spdlog::error("Error while executing: '{}'; Message: {}", cmd, std::strerror(errno));
-                return;
-            }
+            fs::open_custom_action(_pCtConfig->weblinkCustomAct, clean_weblink.raw());
         }
         else {
             fs::open_weblink(clean_weblink);
--- a/src/ct/ct_filesystem.cc
+++ b/src/ct/ct_filesystem.cc
@@ -22,6 +22,8 @@
  */
 
 #include <glibmm/miscutils.h>
+#include <glibmm/shell.h>
+#include <glibmm/spawn.h>
 #include <glib/gstdio.h>
 #include <curl/curl.h>
 #include <system_error>
@@ -272,18 +274,76 @@
     _locale_env_vars_set_for_external_cmd(false/*isPre*/);
 }
 
+static std::vector<std::string> custom_action_build_argv(const std::string& template_cmd, const std::string& target_arg)
+{
+    if (template_cmd.empty()) {
+        return {};
+    }
+    std::vector<std::string> argv;
+    try {
+        argv = Glib::shell_parse_argv(template_cmd);
+    }
+    catch (const Glib::Error& error) {
+        spdlog::error("custom_action_build_argv: failed to parse template '{}': {}", template_cmd, error.what().raw());
+        return {};
+    }
+    if (argv.empty()) {
+        return {};
+    }
+
+    // Strip trailing "&" historically used for shell backgrounding
+    if (argv.back() == "&") {
+        argv.pop_back();
+    }
+    if (argv.empty()) {
+        return {};
+    }
+
+    bool substituted = false;
+    for (auto& arg : argv) {
+        size_t pos = 0;
+        while ((pos = arg.find("%s", pos)) != std::string::npos) {
+            arg.replace(pos, 2, target_arg);
+            pos += target_arg.length();
+            substituted = true;
+        }
+    }
+    if (!substituted) {
+        argv.push_back(target_arg);
+    }
+    return argv;
+}
+
+bool open_custom_action(const std::string& template_cmd, const std::string& target_arg)
+{
+    const std::vector<std::string> argv = custom_action_build_argv(template_cmd, target_arg);
+    if (argv.empty()) {
+        return false;
+    }
+
+    _locale_env_vars_set_for_external_cmd(true/*isPre*/);
+    bool spawned = false;
+    try {
+#if GTKMM_MAJOR_VERSION >= 4
+        Glib::spawn_async("", argv, Glib::SpawnFlags::SEARCH_PATH);
+#else
+        Glib::spawn_async("", argv, Glib::SpawnFlags::SPAWN_SEARCH_PATH);
+#endif
+        spawned = true;
+    }
+    catch (const Glib::Error& error) {
+        spdlog::error("open_custom_action: failed to spawn '{}': {}", argv.front(), error.what().raw());
+    }
+    _locale_env_vars_set_for_external_cmd(false/*isPre*/);
+    return spawned;
+}
+
 // Open Filepath with External App
 void open_filepath(const fs::path& filepath, bool open_folder_if_file_not_exists, CtConfig* config)
 {
     spdlog::debug("fs::open_filepath {}", filepath);
     if (config->filelinkCustomOn) {
-        std::string cmd = fmt::sprintf(config->filelinkCustomAct, filepath.string());
-        _locale_env_vars_set_for_external_cmd(true/*isPre*/);
-        const int retVal = std::system(cmd.c_str());
-        _locale_env_vars_set_for_external_cmd(false/*isPre*/);
-        if (retVal != 0) {
-            spdlog::error("system({}) returned {}", cmd, retVal);
-        }
+        open_custom_action(config->filelinkCustomAct, filepath.string());
     }
     else {
         if (open_folder_if_file_not_exists && !fs::exists(filepath)) {
@@ -304,13 +364,7 @@
 {
     spdlog::debug("fs::open_folderpath {}", folderpath);
     if (config->folderlinkCustomOn) {
-        std::string cmd = fmt::sprintf(config->folderlinkCustomAct, folderpath.string());
-        _locale_env_vars_set_for_external_cmd(true/*isPre*/);
-        const int retVal = std::system(cmd.c_str());
-        _locale_env_vars_set_for_external_cmd(false/*isPre*/);
-        if (retVal != 0) {
-            spdlog::error("system({}) returned {}", cmd, retVal);
-        }
+        open_custom_action(config->folderlinkCustomAct, folderpath.string());
     }
     else {
         _open_path_with_default_app(folderpath);
--- a/src/ct/ct_filesystem.h
+++ b/src/ct/ct_filesystem.h
@@ -81,6 +81,7 @@
 void open_weblink(const std::string& link);
 void open_filepath(const path& filepath, bool open_folder_if_file_not_exists, CtConfig* config);
 void open_folderpath(const path& folderpath, CtConfig* config);
+bool open_custom_action(const std::string& template_cmd, const std::string& target_arg);
 
 path prepare_export_folder(const path& dir_place, path new_folder, bool overwrite_existing);
 

Reply via email to