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.