From 39d38ed3654cece68d8ed31c8a68d00ebaf40c7e Mon Sep 17 00:00:00 2001 From: John Safranek Date: Wed, 21 Oct 2026 09:19:53 -0700 Subject: [PATCH 1/4] echoserver: fix agent forwarding and its cleanup The forwarded agent socket now lives in a mkdtemp() directory, and the session's end runs the agent cleanup, which the library never calls on a server. The relay addresses the agent channel by its own id and drains each agent reply in full. - Set agentChannelId from ssh->agent once the agent channel is open - Drop agent replies when no local agent client is connected - Close the accepted agent client in WOLFSSH_AGENT_LOCAL_CLEANUP and remove the socket and its directory - Print the socket path; openssh-interop.test forwards ssh-agent through the echoserver, lists a reply larger than the relay buffer, and checks the directory is private and removed Issue: F-10553, F-11936, F-14396 --- examples/echoserver/echoserver.c | 113 ++++++++++++++++++++++++------- scripts/openssh-interop.test | 113 +++++++++++++++++++++++++++++++ 2 files changed, 202 insertions(+), 24 deletions(-) diff --git a/examples/echoserver/echoserver.c b/examples/echoserver/echoserver.c index ca45977b0..9a5857b15 100644 --- a/examples/echoserver/echoserver.c +++ b/examples/echoserver/echoserver.c @@ -199,8 +199,12 @@ typedef struct WS_AppCtx { #ifdef WOLFSSH_AGENT +#define ECHOSERVER_AGENT_DIR "/tmp/wolfserver.XXXXXX" + typedef struct WS_AgentCbActionCtx { struct sockaddr_un name; + /* The private directory holding name, empty when there is none. */ + char dirName[sizeof(ECHOSERVER_AGENT_DIR)]; } WS_AgentCbActionCtx; #endif @@ -425,23 +429,34 @@ static int wolfSSH_AGENT_DefaultActions(WS_AgentCbAction action, void* vCtx) if (action == WOLFSSH_AGENT_LOCAL_SETUP) { struct sockaddr_un* name = &agentCtx->name; - size_t size; + size_t size = 0; int envSet = 0, nameBound = 0; WMEMSET(name, 0, sizeof(struct sockaddr_un)); name->sun_family = AF_LOCAL; - ret = snprintf(name->sun_path, sizeof(name->sun_path), - "/tmp/wolfserver.%d", (int)getpid()); + /* mkdtemp() makes a new 0700 directory under an unpredictable name, + * so no other user can claim or reach the socket inside it. */ + WMEMCPY(agentCtx->dirName, ECHOSERVER_AGENT_DIR, + sizeof(ECHOSERVER_AGENT_DIR)); + if (mkdtemp(agentCtx->dirName) == NULL) { + agentCtx->dirName[0] = '\0'; + ret = -1; + } - if (ret >= 0) { - name->sun_path[sizeof(name->sun_path) - 1] = '\0'; - size = WSTRLEN(name->sun_path); - ret = (size < WSTRLEN("/tmp/wolfserver.")); + if (ret == 0) { + ret = snprintf(name->sun_path, sizeof(name->sun_path), + "%s/agent", agentCtx->dirName); + if (ret > 0 && (size_t)ret < sizeof(name->sun_path)) { + size = (size_t)ret + offsetof(struct sockaddr_un, sun_path); + ret = 0; + } + else { + ret = -1; + } } if (ret == 0) { - size += offsetof(struct sockaddr_un, sun_path); ctx->listenFd = socket(AF_UNIX, SOCK_STREAM, 0); ret = (ctx->listenFd == -1) ? -1 : 0; } @@ -462,12 +477,15 @@ static int wolfSSH_AGENT_DefaultActions(WS_AgentCbAction action, void* vCtx) } if (ret == 0) { + printf("Agent socket: %s\n", name->sun_path); + fflush(stdout); ctx->state = APP_STATE_LISTEN; } else { if (nameBound) { unlink(agentCtx->name.sun_path); } + agentCtx->name.sun_path[0] = '\0'; if (envSet) { unsetenv(EnvNameAuthPort); } @@ -475,14 +493,33 @@ static int wolfSSH_AGENT_DefaultActions(WS_AgentCbAction action, void* vCtx) close(ctx->listenFd); ctx->listenFd = -1; } + if (agentCtx->dirName[0] != '\0') { + rmdir(agentCtx->dirName); + agentCtx->dirName[0] = '\0'; + } ret = WS_AGENT_SETUP_E; } } else if (action == WOLFSSH_AGENT_LOCAL_CLEANUP) { - WCLOSESOCKET(ctx->listenFd); - ctx->listenFd = -1; - unlink(agentCtx->name.sun_path); - unsetenv(EnvNameAuthPort); + /* The accepted agent client goes with the listener. */ + if (ctx->appFd >= 0) { + WCLOSESOCKET(ctx->appFd); + ctx->appFd = -1; + } + if (ctx->listenFd >= 0) { + WCLOSESOCKET(ctx->listenFd); + ctx->listenFd = -1; + } + if (agentCtx->name.sun_path[0] != '\0') { + unlink(agentCtx->name.sun_path); + agentCtx->name.sun_path[0] = '\0'; + unsetenv(EnvNameAuthPort); + } + if (agentCtx->dirName[0] != '\0') { + rmdir(agentCtx->dirName); + agentCtx->dirName[0] = '\0'; + } + ctx->state = APP_STATE_INIT; } else ret = WS_AGENT_INVALID_ACTION; @@ -1460,8 +1497,12 @@ static int ssh_worker(thread_ctx_t* threadCtx) if (!agentOpened) { int agentRc = wolfSSH_AGENT_ChannelOpen(ssh); - if (agentRc == WS_SUCCESS) + if (agentRc == WS_SUCCESS) { + /* The relay below addresses the agent channel by its + * own id, which only internal.h exposes. */ + agentChannelId = ssh->agent->channel; agentOpened = 1; + } else if (agentRc == WS_WANT_WRITE) wantWrite = 1; } @@ -1620,23 +1661,41 @@ static int ssh_worker(thread_ctx_t* threadCtx) * pumps below, on every pass. */ #ifdef WOLFSSH_AGENT if (lastChannel == agentChannelId) { - cnt_r = wolfSSH_ChannelIdRead(ssh, agentChannelId, - threadCtx->agentCtx.fdBuffer, - sizeof threadCtx->agentCtx.fdBuffer); - if (cnt_r <= 0) { + int agentRxd = 0; + + /* One message can carry more than fdBuffer + * holds, and no later report asks for the + * rest, so drain it all now. */ + for (;;) { + cnt_r = wolfSSH_ChannelIdRead(ssh, + agentChannelId, + threadCtx->agentCtx.fdBuffer, + sizeof threadCtx->agentCtx.fdBuffer); + if (cnt_r <= 0) + break; + agentRxd = 1; + #ifdef SHELL_DEBUG + buf_dump(threadCtx->agentCtx.fdBuffer, + cnt_r); + #endif + /* A reply for an agent client that has + * hung up has nowhere to go; drop it. */ + if (agentFd >= 0 && app_write_all(agentFd, + threadCtx->agentCtx.fdBuffer, + (word32)cnt_r) < 0) { + agentRxd = -1; + break; + } + } + if (agentRxd < 0) + break; + if (agentRxd == 0) { /* Nothing was buffered. Only an actual data * report makes that a failure. */ if (rc == WS_REKEYING && cnt_r == 0) continue; break; } - #ifdef SHELL_DEBUG - buf_dump(threadCtx->agentCtx.fdBuffer, cnt_r); - #endif - if (app_write_all(agentFd, - threadCtx->agentCtx.fdBuffer, - (word32)cnt_r) < 0) - break; } #endif } @@ -2375,6 +2434,12 @@ static THREAD_RETURN WOLFSSH_THREAD server_worker(void* vArgs) WCLOSESOCKET(threadCtx->fd); threadCtx->fd = -1; } +#ifdef WOLFSSH_AGENT + /* The library never calls the cleanup on a server, so retire the + * listener, its socket and directory, and any agent client here. */ + (void)wolfSSH_AGENT_DefaultActions(WOLFSSH_AGENT_LOCAL_CLEANUP, + &threadCtx->agentCtx); +#endif #ifdef WOLFSSH_FWD if (threadCtx->fwdCbCtx.hostName != NULL) { WFREE(threadCtx->fwdCbCtx.hostName, NULL, 0); diff --git a/scripts/openssh-interop.test b/scripts/openssh-interop.test index 752a6e460..2d46d4033 100755 --- a/scripts/openssh-interop.test +++ b/scripts/openssh-interop.test @@ -10,6 +10,9 @@ # peer that disagreed about the counters would fail the first packet that # had to authenticate. # +# Part 1b reuses the OpenSSH client on the echoserver to forward ssh-agent +# through it. +# # 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 # makes the signature. @@ -242,15 +245,20 @@ echo "Test the OpenSSH client against the wolfSSH echoserver" # A build with neither has only Ed25519, and the checked-in Ed25519 key is # PKCS#8, which an OpenSSH on LibreSSL won't load, so make a key of # OpenSSH's own and hand the echoserver its public half. +# Which of the attempts below opened the session, so the agent forwarding +# check can repeat it. +echo_keys=ecc run_openssh_client "$keys_dir/hansel-key-ecc.pem" -e session_status=$? if [ $session_status -ne 0 ]; then echo "the ECC attempt failed, retrying with the RSA sample key" + echo_keys=rsa run_openssh_client "$keys_dir/hansel-key-rsa.pem" session_status=$? fi if [ $session_status -ne 0 ] && command -v ssh-keygen > /dev/null 2>&1; then echo "the RSA attempt failed, retrying with an Ed25519 key" + echo_keys=ed25519 ssh-keygen -q -t ed25519 -N '' -C hansel -f "$work_dir/ed25519" \ < /dev/null || fail "couldn't make an Ed25519 client key" run_openssh_client "$work_dir/ed25519" -j "$work_dir/ed25519.pub" @@ -284,6 +292,111 @@ fi echo "the OpenSSH client negotiated strict KEX with the echoserver" +# Part 1b forwards OpenSSH's ssh-agent through the echoserver. A local agent +# client on the echoserver's side lists the forwarded key over the socket +# the echoserver made for it, twice, so the second goes through a fresh +# accept. The socket's directory must be private and gone once the session +# is over. +if ! ./apps/wolfssh-options 2>/dev/null | grep -qx 'AGENT'; then + echo "the echoserver has no agent support, skipping agent forwarding" +elif ! command -v ssh-agent > /dev/null 2>&1 || + ! command -v ssh-add > /dev/null 2>&1 || + ! command -v ssh-keygen > /dev/null 2>&1 || + ! command -v mkfifo > /dev/null 2>&1; then + echo "no ssh-agent, ssh-add, ssh-keygen or mkfifo," \ + "skipping agent forwarding" +else + echo "Test agent forwarding through the wolfSSH echoserver" + + # The agent picks its own socket path; see Part 3. + SSH_AGENT_PID= + eval `ssh-agent -s` > /dev/null + [ -n "$SSH_AGENT_PID" ] || fail "couldn't start ssh-agent" + agent_pid=$SSH_AGENT_PID + + ssh-keygen -q -t ed25519 -N '' -C wolfssh-agent-fwd \ + -f "$work_dir/agent-fwd" < /dev/null \ + || fail "couldn't make a key for the agent" + ssh-add -q "$work_dir/agent-fwd" 2>/dev/null \ + || fail "couldn't add the key to ssh-agent" + # Long comments push the list reply past the echoserver's relay + # buffer, so it has to be drained in more than one read. + agent_pad=`printf '%1500s' '' | tr ' ' x` + for pad in 1 2 3 4; do + ssh-keygen -q -t ed25519 -N '' -C "wolfssh-agent-pad$pad-$agent_pad" \ + -f "$work_dir/agent-pad$pad" < /dev/null \ + || fail "couldn't make a padding key for the agent" + ssh-add -q "$work_dir/agent-pad$pad" 2>/dev/null \ + || fail "couldn't add a padding key to ssh-agent" + done + + # The client key was copied to $work_dir/id by the attempt that worked. + case $echo_keys in + ecc) start_echoserver -e ;; + rsa) start_echoserver ;; + *) start_echoserver -j "$work_dir/ed25519.pub" ;; + esac + + # The session stays open while the agent is used through it. + rm -f "$work_dir/fwd_in" "$client_out" "$client_log" + mkfifo "$work_dir/fwd_in" || fail "couldn't make a fifo" + ssh -A -T -p $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" & + fwd_ssh_pid=$! + exec 3> "$work_dir/fwd_in" + printf '%s\n' "$probe" >&3 + + fwd_sock= + counter=0 + while [ -z "$fwd_sock" ] && [ "$counter" -lt 100 ]; do + fwd_sock=`sed -n 's/^Agent socket: //p' "$work_dir/server.log"` + [ -z "$fwd_sock" ] && sleep 0.1 + counter=$((counter + 1)) + done + if [ -z "$fwd_sock" ]; then + exec 3>&- + cat "$work_dir/server.log" + fail "the echoserver never opened an agent socket" + fi + fwd_dir=`dirname "$fwd_sock"` + + case `ls -ld "$fwd_dir"` in + drwx------*) ;; + *) exec 3>&-; fail "the agent socket's directory is not private" ;; + esac + + for attempt in 1 2; do + SSH_AUTH_SOCK="$fwd_sock" ssh-add -l > "$work_dir/fwd_list" 2>&1 & + wait_with_limit $! + if ! grep -q 'wolfssh-agent-fwd' "$work_dir/fwd_list" || + [ `grep -c 'wolfssh-agent-pad' "$work_dir/fwd_list"` -ne 4 ]; then + exec 3>&- + cat "$work_dir/fwd_list" + fail "the forwarded agent didn't list its keys (try $attempt)" + fi + done + + printf '\003' >&3 + exec 3>&- + wait_with_limit $fwd_ssh_pid + wait_with_limit $server_pid + server_pid=$no_pid + + grep -q "$probe" "$client_out" \ + || fail "the session didn't survive the forwarded agent" + [ -e "$fwd_dir" ] && fail "the echoserver left $fwd_dir behind" + + kill $agent_pid 2>/dev/null + agent_pid=$no_pid + + echo "the forwarded agent answered through the echoserver" +fi + # Part 2 needs an sshd to talk to, and a host key to give it. Neither is # worth failing the test over. sshd_bin= From 059ef3327b47a4586f39c6f909590e6d451e0306 Mon Sep 17 00:00:00 2001 From: John Safranek Date: Wed, 21 Oct 2026 09:20:08 -0700 Subject: [PATCH 2/4] echoserver: forward ports only when given -F The forwarding callback connects and binds with the server's own credentials, so it is registered only with the new -F option. Without it the library turns down every forwarding request. - Bind a remote forward for localhost or 127.0.0.1 to the loopback address, not INADDR_ANY - Refuse a remote forward to a privileged port or one over 65535 - Pass -F in fwd.test and fwd-bulk.test; document it in README.md - openssh-interop.test checks a remote forward fails without -F, works with it, and is refused by the port check for port 80 Issue: F-11935 --- README.md | 9 +++-- examples/echoserver/echoserver.c | 42 +++++++++++++++++----- scripts/fwd-bulk.test | 6 ++-- scripts/fwd.test.expect | 2 +- scripts/openssh-interop.test | 62 ++++++++++++++++++++++++++++++-- 5 files changed, 104 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index ac0c024e9..bafc09cbe 100644 --- a/README.md +++ b/README.md @@ -323,12 +323,13 @@ are routed back and forth between the client and server. "Hello, wolfSSL!" 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. To connect with +The echoserver will handle local and remote port forwarding when started +with `-F`; without it every forwarding request is refused. To connect with the ssh tool, using one of the following command lines. You can run either of the ssh command lines from anywhere: src/wolfssl$ ./examples/server/server - src/wolfssh$ ./examples/echoserver/echoserver + src/wolfssh$ ./examples/echoserver/echoserver -F anywhere 1$ ssh -p 22222 -L 12345:localhost:11111 jill@localhost anywhere 2$ ssh -p 22222 -R 12345:localhost:11111 jill@localhost src/wolfssl$ ./examples/client/client -p 12345 @@ -336,6 +337,10 @@ the ssh command lines from anywhere: This will allow port forwarding between the wolfSSL client and server like in the previous example. +A remote forward for `localhost` or `127.0.0.1` listens on the loopback +address only, so with `-R` the wolfSSL client runs on the echoserver's +machine. The echoserver refuses a remote forward of a port below 1024. + SFTP ==== diff --git a/examples/echoserver/echoserver.c b/examples/echoserver/echoserver.c index 9a5857b15..ed88a36d1 100644 --- a/examples/echoserver/echoserver.c +++ b/examples/echoserver/echoserver.c @@ -640,15 +640,16 @@ static int wolfSSH_FwdDefaultActions(WS_FwdCbAction action, void* vCtx, if (ret == 0) { WMEMSET(&addr, 0, sizeof addr); + addr.sin_family = AF_INET; + addr.sin_port = htons((word16)port); + addrSz = sizeof addr; if (WSTRCMP(name, "") == 0 || - WSTRCMP(name, "0.0.0.0") == 0 || - WSTRCMP(name, "localhost") == 0 || + WSTRCMP(name, "0.0.0.0") == 0) { + addr.sin_addr.s_addr = htonl(INADDR_ANY); + } + else if (WSTRCMP(name, "localhost") == 0 || WSTRCMP(name, "127.0.0.1") == 0) { - - addr.sin_addr.s_addr = INADDR_ANY; - addr.sin_family = AF_INET; - addr.sin_port = htons((word16)port); - addrSz = sizeof addr; + addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK); } else { printf("Not using IPv6 yet.\n"); @@ -656,6 +657,13 @@ static int wolfSSH_FwdDefaultActions(WS_FwdCbAction action, void* vCtx, } } + if (ret == 0 && (port > 65535 || + (port != 0 && port < WS_FWD_PORT_CHECK))) { + /* The peer does not get the server's privileged ports. */ + printf("Refusing to forward port %u.\n", port); + ret = -1; + } + if (ret == 0) { ret = bind(appCtx->listenFd, (const struct sockaddr*)&addr, addrSz); @@ -3727,6 +3735,9 @@ static void ShowUsage(void) printf(" -A drive channels from the application callbacks\n"); printf(" -p port to connect on, default %d\n", wolfSshPort); printf(" -N use non-blocking sockets\n"); +#ifdef WOLFSSH_FWD + printf(" -F allow the peer's port forwarding requests\n"); +#endif #ifdef WOLFSSH_SFTP printf(" -d set the home directory for SFTP connections\n"); printf(" -D confine SFTP connections to the home directory," @@ -3778,7 +3789,7 @@ static void ShowUsage(void) } -#define ECHOSERVER_OPTLIST "?1a:Ad:DefEp:R:Ni:j:i:I:J:K:P:k:b:x:m:c:s:G:HW:" +#define ECHOSERVER_OPTLIST "?1a:Ad:DefEFp: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 @@ -3910,6 +3921,9 @@ THREAD_RETURN WOLFSSH_THREAD echoserver_test(void* args) int peerEcc = 0; int echo = 0; int appChannels = 0; +#ifdef WOLFSSH_FWD + int allowFwd = 0; +#endif int ch; word16 port = wolfSshPort; char* readyFile = NULL; @@ -3975,6 +3989,12 @@ THREAD_RETURN WOLFSSH_THREAD echoserver_test(void* args) appChannels = 1; break; + case 'F': + #ifdef WOLFSSH_FWD + allowFwd = 1; + #endif + break; + case 'p': if (myoptarg == NULL) { ES_ERROR("NULL port value\n"); @@ -4216,7 +4236,11 @@ THREAD_RETURN WOLFSSH_THREAD echoserver_test(void* args) wolfSSH_CTX_set_agent_cb(ctx, wolfSSH_AGENT_DefaultActions, NULL); #endif #ifdef WOLFSSH_FWD - wolfSSH_CTX_SetFwdCb(ctx, wolfSSH_FwdDefaultActions, NULL); + /* The callback opens sockets with the server's own credentials, so + * forwarding is refused unless asked for. Without a callback the + * library turns every forwarding request down. */ + if (allowFwd) + wolfSSH_CTX_SetFwdCb(ctx, wolfSSH_FwdDefaultActions, NULL); #endif /* With -A the echoserver drives its own channels: accept() stops at * userauth and these callbacks start the shell, subsystem or transfer. diff --git a/scripts/fwd-bulk.test b/scripts/fwd-bulk.test index 5e69b8ff3..b39af564c 100755 --- a/scripts/fwd-bulk.test +++ b/scripts/fwd-bulk.test @@ -252,7 +252,7 @@ do_tail_attempt() { kill -0 "$tail_nc_server_pid" 2>/dev/null \ || do_fail "couldn't listen on port $tail_target_port, is it in use?" - ./examples/echoserver/echoserver -1 -f -R "$tail_ready_file" \ + ./examples/echoserver/echoserver -1 -f -F -R "$tail_ready_file" \ > "$tail_server_log" 2>&1 & tail_server_pid=$! @@ -333,7 +333,7 @@ then exit 1 fi -./examples/echoserver/echoserver -1 -f -R "$ready_file" \ +./examples/echoserver/echoserver -1 -f -F -R "$ready_file" \ > "$server_log" 2>&1 & server_pid=$! @@ -463,7 +463,7 @@ done [ -s "$echo_target_ready" ] \ || do_fail "couldn't listen on port $echo_target_port, is it in use?" -./examples/echoserver/echoserver -1 -f -R "$echo_ready_file" \ +./examples/echoserver/echoserver -1 -f -F -R "$echo_ready_file" \ > "$echo_server_log" 2>&1 & echo_server_pid=$! diff --git a/scripts/fwd.test.expect b/scripts/fwd.test.expect index a4836fed3..1ef36384b 100755 --- a/scripts/fwd.test.expect +++ b/scripts/fwd.test.expect @@ -190,7 +190,7 @@ proc run_phase {label reverse entry_port target_port} { puts " PID $nc_server_pid - waiting for a connection..." puts "\n\[2\] Starting wolfssh server..." - spawn sh -c "exec ./examples/echoserver/echoserver -1 -f -R $srv_ready >$srv_log 2>&1" + spawn sh -c "exec ./examples/echoserver/echoserver -1 -f -F -R $srv_ready >$srv_log 2>&1" set wolfssh_srv_id $spawn_id set wolfssh_srv_pid [exp_pid] diff --git a/scripts/openssh-interop.test b/scripts/openssh-interop.test index 2d46d4033..d08872028 100755 --- a/scripts/openssh-interop.test +++ b/scripts/openssh-interop.test @@ -10,8 +10,8 @@ # peer that disagreed about the counters would fail the first packet that # had to authenticate. # -# Part 1b reuses the OpenSSH client on the echoserver to forward ssh-agent -# through it. +# 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. # # 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 @@ -397,6 +397,64 @@ else echo "the forwarded agent answered through the echoserver" fi +# Part 1c: the echoserver forwards ports only when started with -F. +# ExitOnForwardFailure turns a refused remote forward into exit status 255. +# +# $1 - the remote port to ask for, 0 to let the server pick +# rest - extra echoserver arguments +run_openssh_forward() { + fwd_port=$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" + printf '%s\n\003' "$probe" | \ + ssh -T -p $port \ + -o ExitOnForwardFailure=yes \ + -R $fwd_port:127.0.0.1:9 \ + -o StrictHostKeyChecking=no \ + -o UserKnownHostsFile=/dev/null \ + -o IdentitiesOnly=yes \ + -o PreferredAuthentications=publickey \ + -i "$work_dir/id" hansel@127.0.0.1 \ + > "$client_out" 2> "$client_log" & + wait_with_limit $! +} + +if ./apps/wolfssh-options 2>/dev/null | grep -qx 'FWD'; then + echo "Test the echoserver's forwarding opt-in" + + run_openssh_forward 0 + [ $? -eq 255 ] && grep -q 'remote port forwarding failed' "$client_log" \ + || fail "the echoserver forwarded a port without -F" + + run_openssh_forward 0 -F + if [ $? -ne 0 ] || ! grep -q "$probe" "$client_out"; then + cat "$client_log" + fail "the echoserver refused a forward with -F" + fi + + # A privileged port is refused by the echoserver's own check, which + # logs it, not by a bind() failing for a non-root user. The server + # exits after the session, flushing its log. + run_openssh_forward 80 -F + fwd_status=$? + wait_with_limit $server_pid + server_pid=$no_pid + if [ $fwd_status -ne 255 ] || + ! grep -q 'remote port forwarding failed' "$client_log" || + ! grep -q 'Refusing to forward port 80\.' "$work_dir/server.log"; then + cat "$client_log" "$work_dir/server.log" + fail "the echoserver didn't refuse a forward of port 80" + fi + + echo "the echoserver forwards only when asked to" +fi + # Part 2 needs an sshd to talk to, and a host key to give it. Neither is # worth failing the test over. sshd_bin= From 1c51f6423101b6991ec5b0a64fdd1227c885301b Mon Sep 17 00:00:00 2001 From: John Safranek Date: Wed, 21 Oct 2026 10:15:06 -0700 Subject: [PATCH 3/4] 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 bafc09cbe..6b6206516 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 ed88a36d1..a680f1f2a 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 @@ -3737,6 +3849,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"); @@ -3789,7 +3904,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 @@ -3948,6 +4063,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) { @@ -3995,6 +4113,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 d08872028..92dc16b02 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 From bc50ab36059424943587ac2ce748bde19a1b74ec Mon Sep 17 00:00:00 2001 From: John Safranek Date: Wed, 21 Oct 2026 10:16:28 -0700 Subject: [PATCH 4/4] echoserver: drop root before serving with -F A root-run echoserver with -F now switches to an unprivileged user once the listener is bound and the keys are loaded, before any peer is served, so forwarded connects and binds no longer run as root. The new -u option names the user; the default is nobody, with a warning. A failed switch ends the server. - Add DropPrivileges(): setgroups, setgid, setuid, then check root cannot be regained - Build it only for the standalone POSIX echoserver with WOLFSSH_FWD, not for the tests that run echoserver_test() in-process - Warn on stderr that -u is ignored in a build without it - Document -u in the usage text and README.md Issue: F-11935 --- README.md | 9 ++-- examples/echoserver/echoserver.c | 77 +++++++++++++++++++++++++++++++- 2 files changed, 82 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 6b6206516..93b61d2cb 100644 --- a/README.md +++ b/README.md @@ -326,9 +326,12 @@ 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. 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: +`host` or `host:port` entries, comma separated. Started as root with +`-F`, the echoserver runs as `nobody` once it is listening; `-u` names +another user. Shells run as that user too, so a login whose home +directory has mode 0700 gets no shell. To connect with the ssh tool, use +one of the following command lines. You can run either of the ssh command +lines from anywhere: src/wolfssl$ ./examples/server/server src/wolfssh$ ./examples/echoserver/echoserver -F diff --git a/examples/echoserver/echoserver.c b/examples/echoserver/echoserver.c index a680f1f2a..e244b814d 100644 --- a/examples/echoserver/echoserver.c +++ b/examples/echoserver/echoserver.c @@ -103,6 +103,18 @@ #include #endif /* WOLFSSH_AGENT */ +#if defined(WOLFSSH_FWD) && !defined(USE_WINDOWS_API) && \ + !defined(WOLFSSL_NUCLEUS) && !defined(WOLFSSH_ZEPHYR) && \ + !defined(NO_MAIN_DRIVER) + /* Gives up root before serving; forwarding opens sockets for the + * peer with the server's credentials. Standalone only: the tests run + * echoserver_test() inside their own process. */ + #define ECHOSERVER_DROP_PRIVS + #include + #include + #include +#endif + #ifdef HAVE_SYS_SELECT_H #include #endif @@ -3833,6 +3845,36 @@ static int SetDefaultSftpPath(WOLFSSH* ssh, const char* defaultSftpPath, #endif +#ifdef ECHOSERVER_DROP_PRIVS +/* Runs the process as userName for good. Without root this only checks + * that userName is the current user. Returns 0, or -1. */ +static int DropPrivileges(const char* userName) +{ + struct passwd* pw; + gid_t gid; + uid_t uid; + + pw = getpwnam(userName); + if (pw == NULL) + return -1; + gid = pw->pw_gid; + uid = pw->pw_uid; + + if (geteuid() != 0) + return (uid == geteuid()) ? 0 : -1; + + if (setgroups(1, &gid) != 0 || setgid(gid) != 0 || setuid(uid) != 0) + return -1; + + /* Make sure root cannot be taken back. */ + if (uid != 0 && (setuid(0) == 0 || seteuid(0) == 0)) + return -1; + + return 0; +} +#endif + + static void ShowUsage(void) { printf("echoserver %s linked with wolfSSL %s\n", LIBWOLFSSH_VERSION_STRING, @@ -3853,6 +3895,10 @@ static void ShowUsage(void) " destinations the peer may connect to, in place\n" " of the default of loopback only; repeatable\n"); #endif +#ifdef ECHOSERVER_DROP_PRIVS + printf(" -u run as user once listening; started as root with\n" + " -F, the default is nobody\n"); +#endif #ifdef WOLFSSH_SFTP printf(" -d set the home directory for SFTP connections\n"); printf(" -D confine SFTP connections to the home directory," @@ -3904,7 +3950,7 @@ static void ShowUsage(void) } -#define ECHOSERVER_OPTLIST "?1a:Ad:DefEFL:p: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:u:" #ifdef WOLFSSH_WINDOWS_CERT_STORE /* Detects whether argv or the environment requests a host key from the @@ -4038,6 +4084,9 @@ THREAD_RETURN WOLFSSH_THREAD echoserver_test(void* args) int appChannels = 0; #ifdef WOLFSSH_FWD int allowFwd = 0; +#endif +#ifdef ECHOSERVER_DROP_PRIVS + const char* dropUser = NULL; #endif int ch; word16 port = wolfSshPort; @@ -4113,6 +4162,15 @@ THREAD_RETURN WOLFSSH_THREAD echoserver_test(void* args) #endif break; + case 'u': + #ifdef ECHOSERVER_DROP_PRIVS + dropUser = myoptarg; + #else + fprintf(stderr, "Warning: -u is ignored in this " + "build\n"); + #endif + break; + case 'L': #ifdef WOLFSSH_FWD if (fwdAllowArgsSz >= FWD_ALLOW_ARGS_MAX) { @@ -4786,6 +4844,23 @@ THREAD_RETURN WOLFSSH_THREAD echoserver_test(void* args) #endif } +#ifdef ECHOSERVER_DROP_PRIVS + /* After the listener and the keys, before any peer is served. */ + if (dropUser == NULL && allowFwd && geteuid() == 0) { + dropUser = "nobody"; + fprintf(stderr, "Warning: running as nobody for -F; -u picks the " + "user.\n"); + #ifdef WOLFSSH_SHELL + fprintf(stderr, "Warning: shells run as nobody too; a login whose " + "home has mode 0700 gets no shell.\n"); + #endif + } + if (dropUser != NULL && DropPrivileges(dropUser) != 0) { + WCLOSESOCKET(listenFd); + ES_ERROR("Couldn't run as user %s.\n", dropUser); + } +#endif + SignalTcpReady(serverArgs->signal, port); do {