From e7b7e8ccfea5064cd5e0b30e53f7a50aa3e30eb7 Mon Sep 17 00:00:00 2001 From: John Safranek Date: Wed, 21 Oct 2026 10:15:06 -0700 Subject: [PATCH] echoserver: limit direct-tcpip destinations With -F a peer's direct-tcpip open is set up only for a loopback destination. The new -L option replaces that default with a list of allowed host or host:port entries. Any other open is refused, which the library reports as administratively prohibited. - Check the destination in the WOLFSSH_FWD_LOCAL_SETUP action - Document -L in the usage text and README.md - openssh-interop.test checks a non-loopback destination and one left out of -L are refused, and that host and host:port entries connect Issue: F-11935 --- README.md | 4 +- examples/echoserver/echoserver.c | 137 +++++++++++++++++++++++++++++-- scripts/openssh-interop.test | 127 +++++++++++++++++++++++++++- 3 files changed, 261 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index bafc09cb..6b620651 100644 --- a/README.md +++ b/README.md @@ -324,7 +324,9 @@ The source for portfwd provides an example on how to set up and use the port forwarding support in wolfSSH. The echoserver will handle local and remote port forwarding when started -with `-F`; without it every forwarding request is refused. To connect with +with `-F`; without it every forwarding request is refused. Local forwards +may connect only to loopback destinations unless `-L` names the allowed +`host` or `host:port` entries, comma separated. To connect with the ssh tool, using one of the following command lines. You can run either of the ssh command lines from anywhere: diff --git a/examples/echoserver/echoserver.c b/examples/echoserver/echoserver.c index a66f4bcd..12c62908 100644 --- a/examples/echoserver/echoserver.c +++ b/examples/echoserver/echoserver.c @@ -573,6 +573,101 @@ static WS_SOCKET_T connect_addr(const char* name, word16 port) } +/* The -L arguments, each a comma separated list of host or host:port. */ +#define FWD_ALLOW_ARGS_MAX 8 +static const char* fwdAllowArgs[FWD_ALLOW_ARGS_MAX]; +static int fwdAllowArgsSz = 0; + + +static int FwdIsLoopback(const char* host) +{ + static const byte loopback6[16] = + { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 }; + struct in_addr addr4; + struct in6_addr addr6; + + if (WSTRLEN(host) == sizeof("localhost") - 1 + && WSTRNCASECMP(host, "localhost", sizeof("localhost") - 1) == 0) + return 1; + if (inet_pton(AF_INET, host, &addr4) == 1) + return (ntohl(addr4.s_addr) >> 24) == 127; + if (inet_pton(AF_INET6, host, &addr6) == 1) + return WMEMCMP(&addr6, loopback6, sizeof(loopback6)) == 0; + return 0; +} + + +/* Matches one allow-list entry, host or host:port, of entrySz bytes. An + * entry with more than one colon is an IPv6 address without a port. */ +static int FwdEntryMatches(const char* entry, word32 entrySz, + const char* host, word32 port) +{ + const char* colon = NULL; + word32 hostSz = entrySz; + word32 i; + + for (i = 0; i < entrySz; i++) { + if (entry[i] == ':') { + if (colon != NULL) { + colon = NULL; + break; + } + colon = entry + i; + } + } + + if (colon != NULL) { + word32 entryPort = 0; + const char* c; + + hostSz = (word32)(colon - entry); + if (colon + 1 == entry + entrySz) + return 0; + for (c = colon + 1; c < entry + entrySz; c++) { + if (*c < '0' || *c > '9' || entryPort > 65535) + return 0; + entryPort = entryPort * 10 + (word32)(*c - '0'); + } + if (entryPort != port) + return 0; + } + + return hostSz > 0 && WSTRLEN(host) == hostSz + && WSTRNCASECMP(host, entry, hostSz) == 0; +} + + +/* Whether the peer may open a direct-tcpip channel to host:port: a loopback + * destination by default, or only the -L entries when there are any. */ +static int FwdDestAllowed(const char* host, word32 port) +{ + int i; + + if (host == NULL) + return 0; + if (fwdAllowArgsSz == 0) + return FwdIsLoopback(host); + + for (i = 0; i < fwdAllowArgsSz; i++) { + const char* entry = fwdAllowArgs[i]; + + while (*entry != '\0') { + const char* comma = WSTRCHR(entry, ','); + word32 entrySz = (comma != NULL) ? (word32)(comma - entry) + : (word32)WSTRLEN(entry); + + if (FwdEntryMatches(entry, entrySz, host, port)) + return 1; + entry += entrySz; + if (*entry == ',') + entry++; + } + } + + return 0; +} + + static int wolfSSH_FwdDefaultActions(WS_FwdCbAction action, void* vCtx, const char* name, word32 port) { @@ -581,10 +676,27 @@ static int wolfSSH_FwdDefaultActions(WS_FwdCbAction action, void* vCtx, int ret = 0; if (action == WOLFSSH_FWD_LOCAL_SETUP) { - fwdCbCtx->hostName = WSTRDUP(name, NULL, 0); - fwdCbCtx->hostPort = port; - fwdCbCtx->isDirect = 1; - appCtx->state = APP_STATE_CONNECT; + if (!FwdDestAllowed(name, port)) { + char shown[65]; + word32 i; + + /* The name is the peer's; log it bounded and printable. */ + for (i = 0; name != NULL && name[i] != '\0' + && i < sizeof(shown) - 1; i++) { + shown[i] = (name[i] >= ' ' && name[i] <= '~') ? name[i] : '?'; + } + shown[i] = '\0'; + /* The library refuses the open as administratively + * prohibited. */ + printf("Refusing to forward to %s:%u.\n", shown, port); + ret = WS_FWD_SETUP_E; + } + else { + fwdCbCtx->hostName = WSTRDUP(name, NULL, 0); + fwdCbCtx->hostPort = port; + fwdCbCtx->isDirect = 1; + appCtx->state = APP_STATE_CONNECT; + } } else if (action == WOLFSSH_FWD_LOCAL_CLEANUP) { /* The channel id rides in the port parameter. A channel can outlive @@ -3738,6 +3850,9 @@ static void ShowUsage(void) printf(" -N use non-blocking sockets\n"); #ifdef WOLFSSH_FWD printf(" -F allow the peer's port forwarding requests\n"); + printf(" -L with -F, the comma separated host or host:port\n" + " destinations the peer may connect to, in place\n" + " of the default of loopback only; repeatable\n"); #endif #ifdef WOLFSSH_SFTP printf(" -d set the home directory for SFTP connections\n"); @@ -3790,7 +3905,7 @@ static void ShowUsage(void) } -#define ECHOSERVER_OPTLIST "?1a:Ad:DefEFp:R:Ni:j:i:I:J:K:P:k:b:x:m:c:s:G:HW:" +#define ECHOSERVER_OPTLIST "?1a:Ad:DefEFL:p:R:Ni:j:i:I:J:K:P:k:b:x:m:c:s:G:HW:" #ifdef WOLFSSH_WINDOWS_CERT_STORE /* Detects whether argv or the environment requests a host key from the @@ -3949,6 +4064,9 @@ THREAD_RETURN WOLFSSH_THREAD echoserver_test(void* args) kbAuthData.promptCount = 0; #endif +#ifdef WOLFSSH_FWD + fwdAllowArgsSz = 0; +#endif if (argc > 0) { myoptind = 0; while ((ch = mygetopt(argc, argv, ECHOSERVER_OPTLIST)) != -1) { @@ -3996,6 +4114,15 @@ THREAD_RETURN WOLFSSH_THREAD echoserver_test(void* args) #endif break; + case 'L': + #ifdef WOLFSSH_FWD + if (fwdAllowArgsSz >= FWD_ALLOW_ARGS_MAX) { + ES_ERROR("Too many -L options.\n"); + } + fwdAllowArgs[fwdAllowArgsSz++] = myoptarg; + #endif + break; + case 'p': if (myoptarg == NULL) { ES_ERROR("NULL port value\n"); diff --git a/scripts/openssh-interop.test b/scripts/openssh-interop.test index d0887202..92dc16b0 100755 --- a/scripts/openssh-interop.test +++ b/scripts/openssh-interop.test @@ -11,7 +11,8 @@ # had to authenticate. # # Parts 1b and 1c reuse the OpenSSH client on the echoserver: 1b forwards -# ssh-agent through it, 1c checks that it forwards ports only with -F. +# ssh-agent through it, 1c checks that it forwards ports only with -F and +# connects only to allowed destinations. # # Part 3 has the wolfSSH client authenticate to sshd with an RSA key held # by OpenSSH's ssh-agent. The client has only the public half; the agent @@ -25,6 +26,7 @@ no_pid=-1 server_pid=$no_pid sshd_pid=$no_pid agent_pid=$no_pid +dest_pid=$no_pid work_dir="`pwd`/openssh_interop_test$$" ready_file="$work_dir/ready" client_out="$work_dir/client.out" @@ -66,6 +68,11 @@ do_cleanup() { kill $agent_pid 2>/dev/null agent_pid=$no_pid fi + if [ $dest_pid != $no_pid ] + then + kill -9 $dest_pid 2>/dev/null + dest_pid=$no_pid + fi rm -rf -- "$work_dir" } @@ -453,6 +460,124 @@ if ./apps/wolfssh-options 2>/dev/null | grep -qx 'FWD'; then fi echo "the echoserver forwards only when asked to" + + # A direct-tcpip open goes only to loopback by default, and only to + # the -L entries when there are any. Refused opens fail at once, so + # 192.0.2.1 (TEST-NET-1) is never dialed. + run_openssh_stdio() { + stdio_dest=$1 + shift + case $echo_keys in + ecc) start_echoserver -e "$@" ;; + rsa) start_echoserver "$@" ;; + *) start_echoserver -j "$work_dir/ed25519.pub" "$@" ;; + esac + + rm -f "$client_out" "$client_log" + ssh -T -W "$stdio_dest" -p $port \ + -o StrictHostKeyChecking=no \ + -o UserKnownHostsFile=/dev/null \ + -o IdentitiesOnly=yes \ + -o PreferredAuthentications=publickey \ + -i "$work_dir/id" hansel@127.0.0.1 \ + < /dev/null > "$client_out" 2> "$client_log" & + wait_with_limit $! + } + + run_openssh_stdio 192.0.2.1:9 -F + [ $? -eq 255 ] && grep -q 'administratively prohibited' "$client_log" \ + || fail "the echoserver forwarded to a non-loopback destination" + + run_openssh_stdio 127.0.0.1:9 -F -L 192.0.2.1:9 + [ $? -eq 255 ] && grep -q 'administratively prohibited' "$client_log" \ + || fail "the echoserver forwarded to a destination -L left out" + + # An allowed open reaches a second echoserver: an echo session with it + # runs through a local forward over the first. $1 is the -L list, with + # @PORT@ standing for the second echoserver's port. + run_openssh_forward_allowed() { + case $echo_keys in + ecc) dest_keys=-e ;; + rsa) dest_keys= ;; + *) dest_keys="-j $work_dir/ed25519.pub" ;; + esac + + # A port to forward from: one the OS just handed out and let go. + start_echoserver + local_port=$port + kill -9 $server_pid 2>/dev/null + wait $server_pid 2>/dev/null + server_pid=$no_pid + + rm -f "$work_dir/dest_ready" + ./examples/echoserver/echoserver -1 -f $dest_keys \ + -R "$work_dir/dest_ready" > "$work_dir/dest.log" 2>&1 & + dest_pid=$! + counter=0 + while [ ! -s "$work_dir/dest_ready" ] && [ "$counter" -lt 100 ]; do + sleep 0.1 + counter=$((counter + 1)) + done + [ ! -s "$work_dir/dest_ready" ] \ + && fail "NO ready file, the destination echoserver never came up" + dest_port=`cat "$work_dir/dest_ready"` + allow_list=`echo "$1" | sed "s/@PORT@/$dest_port/g"` + + start_echoserver $dest_keys -F -L "$allow_list" + + # The outer session stays open while the inner one runs. + rm -f "$work_dir/fwd_in" "$client_out" "$client_log" + mkfifo "$work_dir/fwd_in" || fail "couldn't make a fifo" + ssh -v -T -p $port \ + -o ExitOnForwardFailure=yes \ + -L $local_port:127.0.0.1:$dest_port \ + -o StrictHostKeyChecking=no \ + -o UserKnownHostsFile=/dev/null \ + -o IdentitiesOnly=yes \ + -o PreferredAuthentications=publickey \ + -i "$work_dir/id" hansel@127.0.0.1 \ + < "$work_dir/fwd_in" > "$client_out" 2> "$client_log" & + outer_pid=$! + exec 4> "$work_dir/fwd_in" + counter=0 + while ! grep -q 'Entering interactive session' "$client_log" && + [ "$counter" -lt 100 ]; do + sleep 0.1 + counter=$((counter + 1)) + done + + rm -f "$work_dir/inner_out" + printf '%s\n\003' "$probe" | \ + ssh -T -p $local_port \ + -o StrictHostKeyChecking=no \ + -o UserKnownHostsFile=/dev/null \ + -o IdentitiesOnly=yes \ + -o PreferredAuthentications=publickey \ + -i "$work_dir/id" hansel@127.0.0.1 \ + > "$work_dir/inner_out" 2> "$work_dir/inner_log" & + wait_with_limit $! + + printf '\003' >&4 + exec 4>&- + wait_with_limit $outer_pid + kill -9 $dest_pid 2>/dev/null + wait $dest_pid 2>/dev/null + dest_pid=$no_pid + + grep -q "$probe" "$work_dir/inner_out" + } + + if ! run_openssh_forward_allowed 'example.invalid,127.0.0.1:@PORT@'; then + cat "$client_log" "$work_dir/inner_log" + fail "the echoserver refused a host:port destination -L named" + fi + + if ! run_openssh_forward_allowed '127.0.0.1'; then + cat "$client_log" "$work_dir/inner_log" + fail "the echoserver refused a bare host -L named" + fi + + echo "the echoserver forwards only to allowed destinations" fi # Part 2 needs an sshd to talk to, and a host key to give it. Neither is