Skip to content

Commit b09e8a4

Browse files
Reject symlinks in default SCP send callback
1 parent 8ac0567 commit b09e8a4

3 files changed

Lines changed: 85 additions & 5 deletions

File tree

scripts/scp.test

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,9 @@ do_cleanup() {
5454
kill -9 $server_pid
5555
fi
5656
remove_ready_file
57+
# remove symlink-test artifacts so an early exit (e.g. create_port failure)
58+
# does not leave a planted secret/symlink behind in the source tree
59+
[ -n "$scp_secret" ] && rm -f "$scp_secret" "$scp_symlink" "$scp_symlink_out"
5760
}
5861

5962
do_trap() {
@@ -150,6 +153,46 @@ if test $RESULT -eq 0; then
150153
exit 1
151154
fi
152155

156+
echo "Test that the server refuses to follow a symlink (server to local)"
157+
# This exercises the single-file send path (WOLFSSH_SCP_SINGLE_FILE_REQUEST).
158+
# The recursive directory-traversal sink in ScpProcessEntry uses the same shared
159+
# WS_IsSymlink() helper but cannot be driven from here: the wolfSSH example
160+
# client (wolfSSH_SCP_from) only ever issues "scp -f <path>", never "scp -f -r",
161+
# so the server's recursive request state is never reached by this harness.
162+
# WS_IsSymlink itself is additionally exercised through the SFTP confinement
163+
# unit test (test_wolfSSH_SFTP_Confinement), so the detection logic shared by
164+
# both sinks is covered even though the SCP recursive wiring is not driven e2e.
165+
scp_secret=$PWD/scripts/scp_secret_$$
166+
scp_symlink=$PWD/scp_symlink_$$
167+
scp_symlink_out=$PWD/scp_symlink_out_$$
168+
echo "TOP SECRET SYMLINK TARGET" > $scp_secret
169+
# A symlink whose target exists would, without the fix, be opened and its
170+
# contents streamed to the client. The fix must reject it instead.
171+
ln -s $scp_secret $scp_symlink
172+
if test -L $scp_symlink; then
173+
./examples/echoserver/echoserver -1 -R $ready_file &
174+
server_pid=$!
175+
create_port
176+
./examples/scpclient/wolfscp -u jill -P upthehill -p $port -S $scp_symlink:$scp_symlink_out
177+
remove_ready_file
178+
179+
# The server must not deliver the symlink target: no local output file may
180+
# be produced from the refused request. The client exit code is not asserted
181+
# (as with the other -S cases it does not propagate a server-side SCP abort);
182+
# connectivity in this environment is already proven by the "basic copy from
183+
# server to local" case above, which uses the same mechanism and aborts the
184+
# whole script on failure, so this cannot pass vacuously.
185+
if test -e $scp_symlink_out; then
186+
rm -f $scp_symlink_out $scp_secret $scp_symlink
187+
echo -e "\n\nserver followed a symlink, confinement bypass"
188+
do_cleanup
189+
exit 1
190+
fi
191+
else
192+
echo "symlink not supported on this filesystem, skipping symlink test"
193+
fi
194+
rm -f $scp_secret $scp_symlink $scp_symlink_out
195+
153196
echo -e "\nALL Tests Passed"
154197

155198
exit 0

src/wolfscp.c

Lines changed: 40 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2820,6 +2820,18 @@ static int ScpProcessEntry(WOLFSSH* ssh, char* fileName, word64* mTime,
28202820
WSTRNCPY(fileName, sendCtx->entry->d_name,
28212821
DEFAULT_SCP_FILE_NAME_SZ);
28222822
#endif
2823+
#ifdef WOLFSSH_HAVE_SYMLINK
2824+
/* filePath is now fully built. Reject a planted symlink here,
2825+
* before GetFileStats (which classifies via WSTAT and follows
2826+
* links) or any later descend/open follows it - the whole point is
2827+
* to refuse the link before any file operation traverses it. */
2828+
if (ret == WS_SUCCESS && wIsSymlink(filePath)) {
2829+
WLOG(WS_LOG_ERROR,
2830+
"scp: symlink entry rejected, aborting transfer");
2831+
ret = WS_SCP_ABORT;
2832+
}
2833+
#endif /* WOLFSSH_HAVE_SYMLINK */
2834+
28232835
if (ret == WS_SUCCESS) {
28242836
ret = GetFileStats(ssh->fs, sendCtx, filePath, mTime, aTime, fileMode);
28252837
}
@@ -2862,7 +2874,10 @@ static int ScpProcessEntry(WOLFSSH* ssh, char* fileName, word64* mTime,
28622874
}
28632875

28642876
} else {
2865-
if (ret != WS_NEXT_ERROR) {
2877+
/* WS_SCP_ABORT entries (e.g. a rejected symlink) were already logged at
2878+
* their source, so only the generic, unexpected-error case is noted
2879+
* here to avoid a misleading second log line. */
2880+
if (ret != WS_NEXT_ERROR && ret != WS_SCP_ABORT) {
28662881
WLOG(WS_LOG_ERROR, "scp: ret does not equal WS_NEXT_ERROR, abort");
28672882
ret = WS_SCP_ABORT;
28682883
}
@@ -2981,8 +2996,19 @@ int wsScpSendCallback(WOLFSSH* ssh, int state, const char* peerRequest,
29812996
break;
29822997

29832998
case WOLFSSH_SCP_SINGLE_FILE_REQUEST:
2984-
if ((sendCtx == NULL) || WFOPEN(ssh->fs, &(sendCtx->fp), peerRequest,
2985-
"rb") != 0) {
2999+
#ifdef WOLFSSH_HAVE_SYMLINK
3000+
/* WFOPEN follows symlinks; refuse to open one so its target is not
3001+
* streamed to the peer. */
3002+
if (wIsSymlink(peerRequest)) {
3003+
WLOG(WS_LOG_ERROR, "scp: refusing to open symlink, abort");
3004+
wolfSSH_SetScpErrorMsg(ssh, "unable to open file for reading");
3005+
ret = WS_BAD_FILE_E;
3006+
}
3007+
#endif /* WOLFSSH_HAVE_SYMLINK */
3008+
3009+
if (ret == WS_SUCCESS &&
3010+
((sendCtx == NULL) || WFOPEN(ssh->fs, &(sendCtx->fp),
3011+
peerRequest, "rb") != 0)) {
29863012

29873013
WLOG(WS_LOG_ERROR, "scp: unable to open file, abort");
29883014
wolfSSH_SetScpErrorMsg(ssh, "unable to open file for reading");
@@ -3038,7 +3064,17 @@ int wsScpSendCallback(WOLFSSH* ssh, int state, const char* peerRequest,
30383064

30393065
if (ScpDirStackIsEmpty(sendCtx)) {
30403066

3041-
/* first request, keep track of request directory */
3067+
/* first request, keep track of request directory. The
3068+
* peer-named recursive root is intentionally not run through
3069+
* wIsSymlink here: unlike SFTP there is no library-level
3070+
* trusted base path to contain a resolved root against (SCP
3071+
* confinement in wolfsshd is enforced by OS chroot, inside
3072+
* which the kernel already prevents a symlink from escaping),
3073+
* and wolfSSH_RealPath does not resolve links so a "stays under
3074+
* the root" check is not possible. Symlinks discovered while
3075+
* traversing below the root are still rejected by
3076+
* ScpProcessEntry, which is the planted-link threat this guards
3077+
* against. */
30423078
ret = ScpPushDir(ssh->fs, sendCtx, peerRequest, ssh->ctx->heap);
30433079

30443080
if (ret == WS_SUCCESS) {

wolfssh/port.h

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1534,7 +1534,8 @@ extern "C" {
15341534
/* wIsSymlink lives in the always-compiled port.c, but its filesystem
15351535
* dependencies (WSTAT_T/WLSTAT/S_ISLNK on POSIX, WS_GetFileAttributesExA on
15361536
* Windows) and its only callers exist solely in the SFTP and SCP code, so
1537-
* gate it on those features in addition to the platform capability. */
1537+
* gate it on those features in addition to the platform capability. Shared by
1538+
* the SFTP path-confinement guard and the SCP send guards. */
15381539
#if defined(WOLFSSH_HAVE_SYMLINK) && \
15391540
(defined(WOLFSSH_SFTP) || defined(WOLFSSH_SCP))
15401541
WOLFSSH_LOCAL int wIsSymlink(const char* path);

0 commit comments

Comments
 (0)