This is an automated email from the git hooks/post-receive script.

git pushed a commit to reference refs/pull/43/head
in repository terminology.

View the commit online.

commit aab1ac0a56c4f89e76766519b322f7a47c58b3d9
Author: Cedric BAIL <[email protected]>
AuthorDate: Thu Apr 30 15:42:59 2026 -0600

    security: use POSIX single-quote escaping in shell-out paths
    
    ecore_file_escape_name does NOT escape backtick. When the assembled
    command line contains a backtick (or other shell metachar), ecore_exe_run
    falls through to /bin/sh -c, where the backtick is interpreted as
    command substitution — a Remote Code Execution vulnerability reachable
    from any URL clicked in the terminal output.
    
    Replace ecore_file_escape_name with a local shell_quote helper that
    wraps the argument in POSIX single quotes (immune to all shell
    metacharacters except ' itself, escaped via the '"'"' dance).
    
    Affects:
      - about.c _run_url (previously passed URL verbatim — even worse)
      - media.c media_unknown_handle
      - termio.c _activate_link (email, file, and URL branches)
      - termio.c _smart_media_clicked
      - utils.c open_url
    
    Same approach as elm_open.c in EFL 1.28.
---
 src/bin/about.c      |   8 ++-
 src/bin/media.c      |  11 ++--
 src/bin/termio.c     |  62 +++++++++++------------
 src/bin/tytest.c     |   1 +
 src/bin/unit_tests.h |   1 +
 src/bin/utils.c      | 139 ++++++++++++++++++++++++++++++++++++++++++++++++---
 src/bin/utils.h      |  16 ++++++
 7 files changed, 194 insertions(+), 44 deletions(-)

diff --git a/src/bin/about.c b/src/bin/about.c
index 7216790b..a59c4dc9 100644
--- a/src/bin/about.c
+++ b/src/bin/about.c
@@ -2,9 +2,11 @@
 
 #include <Elementary.h>
 #include <assert.h>
+#include <stdlib.h>
 #include "about.h"
 #include "config.h"
 #include "termio.h"
+#include "utils.h"
 
 #define TWITTER_HANDLE  "@_Terminology_"
 #define YOUTUBE_URL "https://www.youtube.com/channel/UCZ2iBYbbxvcZfcUmnz-rmlQ"
@@ -36,6 +38,7 @@ _run_url(const About_Ctx *ctx,
          const char *url)
 {
    char buf[PATH_MAX];
+   char *quoted;
 #ifdef __APPLE__
    const char *cmd = "open";
 #else
@@ -46,7 +49,10 @@ _run_url(const About_Ctx *ctx,
        ctx->config->helper.url.general[0])
      cmd = ctx->config->helper.url.general;
 
-   snprintf(buf, sizeof(buf), "%s %s", cmd, url);
+   quoted = shell_quote(url);
+   if (!quoted) return;
+   snprintf(buf, sizeof(buf), "%s %s", cmd, quoted);
+   free(quoted);
    ecore_exe_run(buf, NULL);
 }
 
diff --git a/src/bin/media.c b/src/bin/media.c
index 3fd5200e..948f58a7 100644
--- a/src/bin/media.c
+++ b/src/bin/media.c
@@ -10,6 +10,7 @@
 #include "config.h"
 #include "theme.h"
 #include "termiolink.h"
+#include "utils.h"
 
 typedef struct tag_Media Media;
 
@@ -1526,19 +1527,19 @@ media_unknown_handle(const char *handler, const char *src)
 {
    const char *cmd;
    char buf[PATH_MAX];
-   char *escaped;
+   char *quoted;
 #ifdef __APPLE__
    cmd = "open";
 #else
    cmd = "xdg-open";
 #endif
 
-   escaped = ecore_file_escape_name(src);
-   if (!escaped)
+   quoted = shell_quote(src);
+   if (!quoted)
      return;
    if (handler && *handler)
      cmd = handler;
-   snprintf(buf, sizeof(buf), "%s %s", cmd, escaped);
-   free(escaped);
+   snprintf(buf, sizeof(buf), "%s %s", cmd, quoted);
+   free(quoted);
    ecore_exe_run(buf, NULL);
 }
diff --git a/src/bin/termio.c b/src/bin/termio.c
index baad9d0d..11bbef6c 100644
--- a/src/bin/termio.c
+++ b/src/bin/termio.c
@@ -726,7 +726,7 @@ _activate_link(Evas_Object *obj, Eina_Bool may_inline)
 {
    Termio *sd = evas_object_smart_data_get(obj);
    Config *config;
-   char buf[PATH_MAX], *s, *escaped;
+   char buf[PATH_MAX], *s = NULL, *quoted = NULL;
    const char *path = NULL, *cmd = NULL;
    const char *link = NULL;
    Eina_Bool from_escape_code = EINA_FALSE;
@@ -793,13 +793,13 @@ _activate_link(Evas_Object *obj, Eina_Bool may_inline)
         if (casestartswith(s, "mailto:"))
           p += sizeof("mailto:") - 1;
 
-        escaped = ecore_file_escape_name(p);
-        if (escaped)
-          {
-             snprintf(buf, sizeof(buf), "%s %s", cmd, escaped);
-             free(escaped);
-          }
-     }
+        quoted = shell_quote(p);
+        if (quoted)
+           {
+              snprintf(buf, sizeof(buf), "%s %s", cmd, quoted);
+              free(quoted);
+           }
+      }
    else if (path)
      {
         // locally accessible file
@@ -809,8 +809,8 @@ _activate_link(Evas_Object *obj, Eina_Bool may_inline)
         cmd = "xdg-open";
 #endif
 
-        escaped = ecore_file_escape_name(path);
-        if (escaped)
+        quoted = shell_quote(path);
+        if (quoted)
           {
              size_t len = strlen(path);
              Media_Type type = media_src_type_get(path, len);
@@ -851,11 +851,11 @@ _activate_link(Evas_Object *obj, Eina_Bool may_inline)
                            (config->helper.local.general[0]))
                          cmd = config->helper.local.general;
                     }
-                  snprintf(buf, sizeof(buf), "%s %s", cmd, escaped);
-                  free(escaped);
+                  snprintf(buf, sizeof(buf), "%s %s", cmd, quoted);
                }
+             free(quoted);
           }
-     }
+      }
    else if (url)
      {
         // remote file needs ecore-con-url
@@ -865,8 +865,8 @@ _activate_link(Evas_Object *obj, Eina_Bool may_inline)
         cmd = "xdg-open";
 #endif
 
-        escaped = ecore_file_escape_name(s);
-        if (escaped)
+        quoted = shell_quote(s);
+        if (quoted)
           {
              size_t len = strlen(link);
              Media_Type type = media_src_type_get(link, len);
@@ -897,11 +897,11 @@ _activate_link(Evas_Object *obj, Eina_Bool may_inline)
                            (config->helper.url.general[0]))
                          cmd = config->helper.url.general;
                     }
-                  snprintf(buf, sizeof(buf), "%s %s", cmd, escaped);
-                  free(escaped);
+                  snprintf(buf, sizeof(buf), "%s %s", cmd, quoted);
                }
+             free(quoted);
           }
-     }
+      }
    else
      {
         free(s);
@@ -1904,21 +1904,21 @@ _smart_media_clicked(void *data, Evas_Object *obj, void *_info EINA_UNUSED)
                        if ((config->helper.local.general) &&
                            (config->helper.local.general[0]))
                          cmd = config->helper.local.general;
-                       if (cmd)
-                         {
-                            char *escaped;
+                        if (cmd)
+                          {
+                             char *quoted;
 
-                            escaped = ecore_file_escape_name(file);
-                            if (escaped)
-                              {
-                                 char buf[PATH_MAX];
+                             quoted = shell_quote(file);
+                             if (quoted)
+                               {
+                                  char buf[PATH_MAX];
 
-                                 snprintf(buf, sizeof(buf), "%s %s", cmd, escaped);
-                                 ecore_exe_run(buf, NULL);
-                                 free(escaped);
-                              }
-                            return;
-                         }
+                                  snprintf(buf, sizeof(buf), "%s %s", cmd, quoted);
+                                  ecore_exe_run(buf, NULL);
+                                  free(quoted);
+                               }
+                             return;
+                          }
                     }
                   file = blk->link;
                }
diff --git a/src/bin/tytest.c b/src/bin/tytest.c
index 02903830..b48503fb 100644
--- a/src/bin/tytest.c
+++ b/src/bin/tytest.c
@@ -42,6 +42,7 @@ static struct {
        { "color_parse_css_hsl", tytest_color_parse_css_hsl},
        { "extn_matching", tytest_extn_matching},
        { "base64", tytest_base64},
+       { "shell_quote", tytest_shell_quote},
        { NULL, NULL},
 };
 
diff --git a/src/bin/unit_tests.h b/src/bin/unit_tests.h
index ed3ddd94..708163b3 100644
--- a/src/bin/unit_tests.h
+++ b/src/bin/unit_tests.h
@@ -19,5 +19,6 @@ int tytest_color_parse_css_rgb(void);
 int tytest_color_parse_css_hsl(void);
 int tytest_extn_matching(void);
 int tytest_base64(void);
+int tytest_shell_quote(void);
 
 #endif
diff --git a/src/bin/utils.c b/src/bin/utils.c
index 2884207d..618fbf79 100644
--- a/src/bin/utils.c
+++ b/src/bin/utils.c
@@ -7,9 +7,52 @@
 #include <Emile.h>
 
 #include <assert.h>
+#include <stdlib.h>
 #include <unistd.h>
 #include <pwd.h>
 
+char *
+shell_quote(const char *arg)
+{
+   const char *p;
+   char       *quoted, *q;
+   size_t      quoted_len;
+
+   if (!arg) return NULL;
+
+   /* Size the output:
+    *   2 chars for outer single-quotes
+    *   each ' in arg expands to 5 chars: '"'"'
+    *   all other chars are 1 char each
+    *   +1 for trailing NUL
+    */
+   quoted_len = 2;
+   for (p = arg; *p; p++)
+     quoted_len += (*p == '\'') ? 5 : 1;
+
+   quoted = malloc(quoted_len + 1);
+   if (!quoted) return NULL;
+
+   q = quoted;
+   *q++ = '\'';
+   for (p = arg; *p; p++)
+     {
+        if (*p == '\'')
+          {
+             *q++ = '\'';
+             *q++ = '"';
+             *q++ = '\'';
+             *q++ = '"';
+             *q++ = '\'';
+          }
+        else
+          *q++ = *p;
+     }
+   *q++ = '\'';
+   *q   = '\0';
+   return quoted;
+}
+
 Eina_Bool
 homedir_get(char *buf, size_t size)
 {
@@ -32,7 +75,7 @@ homedir_get(char *buf, size_t size)
 void
 open_url(const Config *config, const char *url)
 {
-   char buf[PATH_MAX], *s = NULL, *escaped = NULL;
+   char buf[PATH_MAX], *s = NULL, *quoted = NULL;
    const char *cmd;
    const char *prefix = "http://";
    Eina_Strbuf *sb = NULL;
@@ -53,7 +96,9 @@ open_url(const Config *config, const char *url)
    eina_strbuf_append(sb, url);
    eina_strbuf_trim(sb);
 
-   s = eina_str_escape(eina_strbuf_string_get(sb));
+   s = eina_strbuf_string_steal(sb);
+   eina_strbuf_free(sb);
+   sb = NULL;
    if (!s)
      goto end;
    if (casestartswith(s, "http://") ||
@@ -62,18 +107,17 @@ open_url(const Config *config, const char *url)
         casestartswith(s, "mailto:"))
      prefix = "";
 
-   escaped = ecore_file_escape_name(s);
-   if (!escaped)
+   quoted = shell_quote(s);
+   if (!quoted)
      goto end;
 
-   snprintf(buf, sizeof(buf), "%s %s%s", cmd, prefix, escaped);
+   snprintf(buf, sizeof(buf), "%s %s%s", cmd, prefix, quoted);
 
    WRN("trying to launch '%s'", buf);
    ecore_exe_run(buf, NULL);
 
 end:
-   eina_strbuf_free(sb);
-   free(escaped);
+   free(quoted);
    free(s);
 }
 
@@ -145,4 +189,85 @@ int tytest_base64(void)
 
    return 0;
 }
+
+int tytest_shell_quote(void)
+{
+   char *q;
+
+   /* 1. Empty string */
+   q = shell_quote("");
+   assert(q);
+   assert(strcmp(q, "''") == 0);
+   free(q);
+
+   /* 2. Plain text (no metachars) */
+   q = shell_quote("hello");
+   assert(q);
+   assert(strcmp(q, "'hello'") == 0);
+   free(q);
+
+   /* 3. Spaces */
+   q = shell_quote("hello world");
+   assert(q);
+   assert(strcmp(q, "'hello world'") == 0);
+   free(q);
+
+   /* 4. Backtick — the bug we are fixing.
+    *    Inside single quotes, backtick is literal. */
+   q = shell_quote("`id`");
+   assert(q);
+   assert(strcmp(q, "'`id`'") == 0);
+   free(q);
+
+   /* 5. Dollar + parens (command substitution attempt) */
+   q = shell_quote("$(whoami)");
+   assert(q);
+   assert(strcmp(q, "'$(whoami)'") == 0);
+   free(q);
+
+   /* 6. Backslash (literal inside single quotes) */
+   q = shell_quote("a\\b");
+   assert(q);
+   assert(strcmp(q, "'a\\b'") == 0);
+   free(q);
+
+   /* 7. Newline (literal inside single quotes) */
+   q = shell_quote("a\nb");
+   assert(q);
+   assert(strcmp(q, "'a\nb'") == 0);
+   free(q);
+
+   /* 8. Single quote alone — the only character that needs escaping.
+    *    ' becomes '"'"' (close-SQ, open-DQ, lit-SQ, close-DQ, open-SQ).
+    *    Wrapped in outer quotes: ''"'"''  (7 bytes) */
+   q = shell_quote("'");
+   assert(q);
+   assert(strcmp(q, "''\"'\"''") == 0);
+   free(q);
+
+   /* 9. One single quote in middle */
+   q = shell_quote("o'reilly");
+   assert(q);
+   assert(strcmp(q, "'o'\"'\"'reilly'") == 0);
+   free(q);
+
+   /* 10. Multiple single quotes */
+   q = shell_quote("'a'");
+   assert(q);
+   assert(strcmp(q, "''\"'\"'a'\"'\"''") == 0);
+   free(q);
+
+   /* 11. NULL input returns NULL gracefully */
+   q = shell_quote(NULL);
+   assert(q == NULL);
+
+   /* 12. Mix of all metacharacters in one string.
+    *     Inside single quotes, every byte except ' is literal. */
+   q = shell_quote("`$&|;<>(){}[]!#*?~\"");
+   assert(q);
+   assert(strcmp(q, "'`$&|;<>(){}[]!#*?~\"'") == 0);
+   free(q);
+
+   return 0;
+}
 #endif
diff --git a/src/bin/utils.h b/src/bin/utils.h
index 1c936a07..feb7cf2b 100644
--- a/src/bin/utils.h
+++ b/src/bin/utils.h
@@ -8,4 +8,20 @@ Eina_Bool homedir_get(char *buf, size_t size);
 void open_url(const Config *config, const char *url);
 
 char * ty_eina_unicode_base64_decode(Eina_Unicode *c);
+
+/* POSIX single-quote shell escaping.  Wraps arg in single quotes and
+ * escapes any embedded single-quote via the standard '"'"' dance.
+ *
+ * Returns a newly malloc'd string; caller must free().
+ * Returns NULL on allocation failure or NULL input.
+ *
+ * Single-quoted strings in POSIX sh are immune to ALL metacharacters
+ * (backtick, $, \, newline, …).  The only character that cannot appear
+ * literally inside single-quotes is ' itself, handled via the '"'"' idiom.
+ *
+ * This is safer than ecore_file_escape_name(), which does NOT escape
+ * backtick — a known shell command-substitution injection class.
+ */
+char *shell_quote(const char *arg);
+
 #endif

-- 
To stop receiving notification emails like this one, please contact
the administrator of this repository.

Reply via email to