From fc9963d29e12404975a8ef4bf828f439fe63dc5a Mon Sep 17 00:00:00 2001 From: Nuhiat-Arefin Date: Tue, 28 Apr 2026 00:31:20 +0600 Subject: [PATCH] config: add %n expansion and defer HostName normalization Add %n support to path expansion. Defer HostName handling so the expanded value is computed even when the final host cannot be applied yet. HostName specific expansion lowercases literal hostname text and %h expansions, while unsupported HostName %X tokens are preserved literally and normal host validation still applies when the result is passed to SSH_OPTIONS_HOST. This keeps the expansion logic correct now and leaves room for future HostName token support without changing the deferred path again. Signed-off-by: Nuhiat-Arefin Reviewed-by: Jakub Jelen Merge-Request: --- include/libssh/misc.h | 3 + include/libssh/session.h | 1 + src/config.c | 142 ++++++++++++++++++++++++++++++++++++--- src/misc.c | 131 +++++++++++++++++++++++++++++------- src/options.c | 49 ++++++++++++++ src/session.c | 1 + 6 files changed, 292 insertions(+), 35 deletions(-) diff --git a/include/libssh/misc.h b/include/libssh/misc.h index 6e959cdd..1dc5ac59 100644 --- a/include/libssh/misc.h +++ b/include/libssh/misc.h @@ -53,7 +53,10 @@ int ssh_file_readaccess_ok(const char *file); int ssh_dir_writeable(const char *path); char *ssh_path_expand_tilde(const char *d); +/* Expand a string using session option values. */ char *ssh_path_expand_escape(ssh_session session, const char *s); +/* Expand HostName tokens. */ +char *ssh_path_expand_hostname(ssh_session session, const char *s); int ssh_analyze_banner(ssh_session session, int server); int ssh_is_ipaddr_v4(const char *str); int ssh_is_ipaddr(const char *str); diff --git a/include/libssh/session.h b/include/libssh/session.h index 1eea87d7..f926b223 100644 --- a/include/libssh/session.h +++ b/include/libssh/session.h @@ -290,6 +290,7 @@ struct ssh_session_struct { int address_family; bool batch_mode; char *originalhost; /* user-supplied host for config matching */ + char *config_hostname; /* normalized HostName pattern, applied later */ bool config_hostname_only; /* config hostname path: update host only, not originalhost */ char *tag; /* configuration tag for Match tagged */ diff --git a/src/config.c b/src/config.c index 8f21d1b7..91879cb5 100644 --- a/src/config.c +++ b/src/config.c @@ -534,6 +534,91 @@ ssh_match_exec(ssh_session session, const char *command, bool negate) } #endif /* WITH_EXEC */ +/* + * HostName recognizes %% and %h during config parsing. Other %X sequences are + * left for deferred HostName expansion, and only a trailing bare '%' is + * rejected here. + */ +static int ssh_config_scan_hostname_tokens(ssh_session session, + const char *hostname, + bool *needs_host, + bool *has_unknown) +{ + const char *p = NULL; + + if (needs_host != NULL) { + *needs_host = false; + } + if (has_unknown != NULL) { + *has_unknown = false; + } + + if (hostname == NULL) { + ssh_set_error(session, + SSH_FATAL, + "Cannot scan HostName tokens from NULL input"); + return -1; + } + + for (p = hostname; *p != '\0'; p++) { + if (*p == '%') { + if (p[1] == '\0') { + ssh_set_error(session, SSH_FATAL, "Incomplete Hostname token"); + return -1; + } + switch (p[1]) { + case '%': + p++; + continue; + case 'h': + if (needs_host != NULL) { + *needs_host = true; + } + p++; + continue; + default: + if (has_unknown != NULL) { + *has_unknown = true; + } + p++; + continue; + } + } + } + + return 0; +} + +static char *ssh_config_lowercase_hostname_pattern(const char *hostname) +{ + char *pattern = NULL; + char *p = NULL; + bool escape = false; + + if (hostname == NULL) { + return NULL; + } + + pattern = strdup(hostname); + if (pattern == NULL) { + return NULL; + } + + for (p = pattern; *p != '\0'; p++) { + if (escape) { + escape = false; + continue; + } + if (*p == '%') { + escape = true; + continue; + } + *p = tolower((unsigned char)*p); + } + + return pattern; +} + /** * @brief: Parse the ProxyJump configuration line and if parsing, * stores the result in the configuration option @@ -1409,25 +1494,60 @@ static int ssh_config_parse_line_internal(ssh_session session, p = ssh_config_get_str_tok(&s, NULL); CHECK_COND_OR_FAIL(p == NULL, "Missing argument"); if (*parsing) { - char *z = NULL; - char *lower = NULL; - z = ssh_path_expand_escape(session, p); - if (z == NULL) { - z = strdup(p); + int rc; + bool had_expansion = strchr(p, '%') != NULL; + bool needs_host = false; + bool has_unknown = false; + rc = ssh_config_scan_hostname_tokens(session, + p, + &needs_host, + &has_unknown); + if (rc < 0) { + SAFE_FREE(x); + return -1; } - if (z != NULL) { - /* Always lowercase hostname */ - lower = ssh_lowercase(z); + if (had_expansion) { + if (!has_unknown && + (!needs_host || session->opts.host != NULL || + session->opts.originalhost != NULL)) { + char *expanded = ssh_path_expand_hostname(session, p); + if (expanded == NULL) { + SAFE_FREE(x); + return -1; + } + session->opts.config_hostname_only = true; + rv = ssh_options_set(session, SSH_OPTIONS_HOST, expanded); + session->opts.config_hostname_only = false; + free(expanded); + if (rv != SSH_OK) { + /* Expanded HostName values remain fatal if host + * validation rejects the resulting hostname. + */ + SAFE_FREE(x); + return -1; + } + } else { + char *hostname_pattern = + ssh_config_lowercase_hostname_pattern(p); + if (hostname_pattern == NULL) { + ssh_set_error_oom(session); + SAFE_FREE(x); + return -1; + } + SAFE_FREE(session->opts.config_hostname); + session->opts.config_hostname = hostname_pattern; + } + } else { + char *lower = ssh_lowercase(p); if (lower == NULL) { - SAFE_FREE(z); + ssh_set_error_oom(session); SAFE_FREE(x); return -1; } session->opts.config_hostname_only = true; ssh_options_set(session, SSH_OPTIONS_HOST, lower); - free(lower); session->opts.config_hostname_only = false; - free(z); + free(lower); } } break; diff --git a/src/misc.c b/src/misc.c index 711246e1..b2bfd500 100644 --- a/src/misc.c +++ b/src/misc.c @@ -1469,26 +1469,12 @@ err: return NULL; } -/** @internal - * @brief expands a string in function of session options - * - * @param[in] session The SSH session providing option values for expansion. - * @param[in] s Format string to expand. Known parameters: - * - %d user home directory (~) - * - %h target host name - * - %u local username - * - %l local hostname - * - %r remote username - * - %p remote port - * - %j proxyjump string - * - %C Hash of %l%h%p%r%j - * - * @returns Expanded string. The caller needs to free the memory using - * ssh_string_free_char(). - * - * @see ssh_string_free_char() +/* Internal expansion helper. hostname_lenient preserves unknown %X tokens + * literally for HostName handling. */ -char *ssh_path_expand_escape(ssh_session session, const char *s) +static char *ssh_path_expand_internal(ssh_session session, + const char *s, + bool hostname_lenient) { char *buf = NULL; char *r = NULL; @@ -1520,8 +1506,7 @@ char *ssh_path_expand_escape(ssh_session session, const char *s) for (i = 0; *p != '\0'; p++) { if (*p != '%') { - escape: - buf[i] = *p; + buf[i] = hostname_lenient ? tolower((unsigned char)*p) : *p; i++; if (i >= MAX_BUF_SIZE) { free(buf); @@ -1534,12 +1519,51 @@ char *ssh_path_expand_escape(ssh_session session, const char *s) p++; if (*p == '\0') { + /* HostName expansion rejects trailing '%' to match the parse-time + * scan. Keep the general expansion path unchanged, where a + * trailing '%' is truncated. + */ + if (hostname_lenient) { + ssh_set_error(session, SSH_FATAL, "Incomplete Hostname token"); + free(buf); + free(r); + return NULL; + } break; } + if (hostname_lenient && *p != '%' && *p != 'h') { + buf[i] = '%'; + i++; + if (i >= MAX_BUF_SIZE) { + ssh_set_error(session, SSH_FATAL, "String too long"); + free(buf); + free(r); + return NULL; + } + buf[i] = *p; + i++; + if (i >= MAX_BUF_SIZE) { + ssh_set_error(session, SSH_FATAL, "String too long"); + free(buf); + free(r); + return NULL; + } + buf[i] = '\0'; + continue; + } + switch (*p) { case '%': - goto escape; + buf[i] = '%'; + i++; + if (i >= MAX_BUF_SIZE) { + free(buf); + free(r); + return NULL; + } + buf[i] = '\0'; + continue; case 'd': x = ssh_get_user_home_dir(session); if (x == NULL) { @@ -1557,9 +1581,11 @@ char *ssh_path_expand_escape(ssh_session session, const char *s) break; case 'h': if (session->opts.host) { - x = strdup(session->opts.host); + x = hostname_lenient ? ssh_lowercase(session->opts.host) + : strdup(session->opts.host); } else if (session->opts.originalhost) { - x = strdup(session->opts.originalhost); + x = hostname_lenient ? ssh_lowercase(session->opts.originalhost) + : strdup(session->opts.originalhost); } else { ssh_set_error(session, SSH_FATAL, "Cannot expand host"); free(buf); @@ -1567,6 +1593,18 @@ char *ssh_path_expand_escape(ssh_session session, const char *s) return NULL; } break; + case 'n': + if (session->opts.originalhost) { + x = strdup(session->opts.originalhost); + } else { + ssh_set_error(session, + SSH_FATAL, + "Cannot expand original host"); + free(buf); + free(r); + return NULL; + } + break; case 'r': if (session->opts.username) { x = strdup(session->opts.username); @@ -1635,6 +1673,51 @@ char *ssh_path_expand_escape(ssh_session session, const char *s) return x; } +/** + * @brief Expand a string using session option values. + * + * @param[in] session The SSH session providing option values for expansion. + * @param[in] s Format string to expand. + * + * Supported tokens: + * - %d user home directory (~) + * - %h target host name + * - %u local username + * - %l local hostname + * - %r remote username + * - %p remote port + * - %j proxyjump string + * - %C Hash of %l%h%p%r%j + * - %n original target host name + * + * @returns Expanded string. The caller needs to free the memory using + * ssh_string_free_char(). + * + * @see ssh_string_free_char() + */ +char *ssh_path_expand_escape(ssh_session session, const char *s) +{ + return ssh_path_expand_internal(session, s, false); +} + +/** + * @brief Expand HostName tokens. + * + * @param[in] session The SSH session providing option values for expansion. + * @param[in] s HostName pattern to expand. + * + * Like ssh_path_expand_escape(), but only interprets %% and %h. Other %X + * tokens are preserved literally. Literal hostname text and %h expansions are + * normalized to lowercase. + * + * @returns Expanded string. The caller needs to free the memory using + * ssh_string_free_char(). + */ +char *ssh_path_expand_hostname(ssh_session session, const char *s) +{ + return ssh_path_expand_internal(session, s, true); +} + /** * @internal * diff --git a/src/options.c b/src/options.c index 92fcca7d..fb33bb13 100644 --- a/src/options.c +++ b/src/options.c @@ -124,6 +124,14 @@ int ssh_options_copy(ssh_session src, ssh_session *dest) } } + if (src->opts.config_hostname != NULL) { + new->opts.config_hostname = strdup(src->opts.config_hostname); + if (new->opts.config_hostname == NULL) { + ssh_free(new); + return -1; + } + } + if (src->opts.bindaddr != NULL) { new->opts.bindaddr = strdup(src->opts.bindaddr); if (new->opts.bindaddr == NULL) { @@ -782,6 +790,7 @@ int ssh_options_set(ssh_session session, session->opts.username = username; } if (!session->opts.config_hostname_only) { + SAFE_FREE(session->opts.config_hostname); SAFE_FREE(session->opts.originalhost); session->opts.originalhost = hostname; } else { @@ -2270,6 +2279,46 @@ int ssh_options_apply(ssh_session session) } } + if (session->opts.config_hostname != NULL) { + char *saved_host = NULL; + + tmp = ssh_path_expand_hostname(session, session->opts.config_hostname); + if (tmp == NULL) { + return -1; + } + if (session->opts.host != NULL) { + saved_host = strdup(session->opts.host); + if (saved_host == NULL) { + free(tmp); + ssh_set_error_oom(session); + return -1; + } + } + session->opts.config_hostname_only = true; + rc = ssh_options_set(session, SSH_OPTIONS_HOST, tmp); + session->opts.config_hostname_only = false; + if (rc != SSH_OK) { + /* If HostName expansion leaves a literal '%', keep the current + * host instead of treating the deferred HostName as fatal. + */ + if (strchr(tmp, '%') == NULL) { + SAFE_FREE(saved_host); + free(tmp); + return -1; + } + SSH_LOG(SSH_LOG_WARN, + "HostName %s contains unknown expansion tokens and could " + "not be applied; falling back to current host", + tmp); + SAFE_FREE(session->opts.host); + session->opts.host = saved_host; + saved_host = NULL; + } + SAFE_FREE(saved_host); + free(tmp); + SAFE_FREE(session->opts.config_hostname); + } + if ((session->opts.exp_flags & SSH_OPT_EXP_FLAG_KNOWNHOSTS) == 0) { if (session->opts.knownhosts == NULL) { tmp = ssh_path_expand_escape(session, "%d/.ssh/known_hosts"); diff --git a/src/session.c b/src/session.c index cfc20189..6adf95ec 100644 --- a/src/session.c +++ b/src/session.c @@ -408,6 +408,7 @@ void ssh_free(ssh_session session) SAFE_FREE(session->opts.username); SAFE_FREE(session->opts.host); SAFE_FREE(session->opts.originalhost); + SAFE_FREE(session->opts.config_hostname); SAFE_FREE(session->opts.tag); SAFE_FREE(session->opts.homedir); SAFE_FREE(session->opts.sshdir);