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 <nuhiatarefin@gmail.com>
Reviewed-by: Jakub Jelen <jjelen@redhat.com>
Merge-Request: <https://gitlab.com/libssh/libssh-mirror/-/merge_requests/811>
This commit is contained in:
Nuhiat-Arefin
2026-04-28 00:31:20 +06:00
committed by Jakub Jelen
parent 77ef6379a5
commit fc9963d29e
6 changed files with 292 additions and 35 deletions

View File

@@ -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);

View File

@@ -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 */

View File

@@ -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;

View File

@@ -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
*

View File

@@ -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");

View File

@@ -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);