Skip to content

Commit 13f11a3

Browse files
Reject symlinks in default SCP send callback
1 parent 52f6db9 commit 13f11a3

8 files changed

Lines changed: 446 additions & 11 deletions

File tree

examples/client/client.c

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,11 @@ static void ShowUsage(void)
129129
printf(" -k set the list of key algos\n");
130130
printf(" -C set the list of encrypt algos\n");
131131
printf(" -q turn off debugging output\n");
132+
#ifndef WOLFSSH_HAVE_SYMLINK
133+
/* report disabled symlink checking so test scripts can skip the
134+
* symlink-rejection cases the server would otherwise follow */
135+
printf(" symlink checking off (e.g. WOLFSSH_NO_SYMLINK_CHECK)\n");
136+
#endif
132137
}
133138

134139

scripts/scp.test

Lines changed: 56 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,59 @@ if test $RESULT -eq 0; then
150153
exit 1
151154
fi
152155

156+
# The symlink-rejection guard is compiled out by WOLFSSH_NO_SYMLINK_CHECK, which
157+
# the example client reports in its usage output (-?). Skip this test when it is
158+
# set: the server then follows symlinks by design and the check below would
159+
# false-fail.
160+
./examples/client/client '-?' | grep WOLFSSH_NO_SYMLINK_CHECK
161+
if [ $? -eq 0 ]; then
162+
echo "symlink checking disabled, skipping symlink test"
163+
else
164+
echo "Test that the server refuses to follow a symlink (server to local)"
165+
# This exercises the single-file send path (WOLFSSH_SCP_SINGLE_FILE_REQUEST),
166+
# whose file open now goes through wFopenNoFollow (atomic O_NOFOLLOW on
167+
# POSIX), so this case drives the no-follow open end to end.
168+
# The recursive sinks (the recursive-root leaf check and the per-entry check
169+
# in ScpProcessEntry) use the same shared wIsSymlink helper but cannot be
170+
# driven from here: the wolfSSH example client (wolfSSH_SCP_from) only ever
171+
# issues "scp -f <path>", never "scp -f -r", so the server's recursive
172+
# request state is never reached by this harness. wIsSymlink itself is
173+
# additionally exercised through the SFTP confinement unit test
174+
# (test_wolfSSH_SFTP_Confinement), so the detection logic shared by all sinks
175+
# is covered even though the SCP recursive wiring is not driven e2e.
176+
scp_secret=$PWD/scripts/scp_secret_$$
177+
scp_symlink=$PWD/scp_symlink_$$
178+
scp_symlink_out=$PWD/scp_symlink_out_$$
179+
echo "TOP SECRET SYMLINK TARGET" > $scp_secret
180+
# A symlink whose target exists would, without the fix, be opened and its
181+
# contents streamed to the client. The fix must reject it instead.
182+
ln -s $scp_secret $scp_symlink
183+
if test -L $scp_symlink; then
184+
./examples/echoserver/echoserver -1 -R $ready_file &
185+
server_pid=$!
186+
create_port
187+
./examples/scpclient/wolfscp -u jill -P upthehill -p $port -S $scp_symlink:$scp_symlink_out
188+
remove_ready_file
189+
190+
# The server must not deliver the symlink target: no local output file
191+
# may be produced from the refused request. The client exit code is not
192+
# asserted (as with the other -S cases it does not propagate a
193+
# server-side SCP abort); connectivity in this environment is already
194+
# proven by the "basic copy from server to local" case above, which uses
195+
# the same mechanism and aborts the whole script on failure, so this
196+
# cannot pass vacuously.
197+
if test -e $scp_symlink_out; then
198+
rm -f $scp_symlink_out $scp_secret $scp_symlink
199+
echo -e "\n\nserver followed a symlink, confinement bypass"
200+
do_cleanup
201+
exit 1
202+
fi
203+
else
204+
echo "symlink not supported on this filesystem, skipping symlink test"
205+
fi
206+
rm -f $scp_secret $scp_symlink $scp_symlink_out
207+
fi
208+
153209
echo -e "\nALL Tests Passed"
154210

155211
exit 0

src/internal.c

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1415,6 +1415,10 @@ void SshResourceFree(WOLFSSH* ssh, void* heap)
14151415
ssh->scpBasePathDynamic = NULL;
14161416
ssh->scpBasePathSz = 0;
14171417
}
1418+
#if !defined(WOLFSSH_SCP_USER_CALLBACKS) && !defined(NO_FILESYSTEM)
1419+
/* free any send-side directory stack left by an aborted recursive transfer */
1420+
ScpSendCtxFreeDirs(ssh->fs, &ssh->scpSendCbCtx, heap);
1421+
#endif
14181422
#endif
14191423
#ifdef WOLFSSH_SFTP
14201424
if (ssh->sftpDefaultPath) {

src/port.c

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -719,6 +719,82 @@ int wIsSymlink(const char* path)
719719
#endif
720720
return isLink;
721721
}
722+
723+
/* Open path for reading without following a final-component symbolic link.
724+
* On POSIX this is atomic through O_NOFOLLOW: the open itself fails if the leaf
725+
* is a link, closing the check-then-open race. Where no such primitive exists
726+
* (Windows, or O_NOFOLLOW undefined) it falls back to a best-effort wIsSymlink
727+
* test before the follow-prone open. Returns 0 on success and non-zero on
728+
* failure, matching the wfopen convention. */
729+
int wFopenNoFollow(void* fs, WFILE** f, const char* path)
730+
{
731+
int ret = 0;
732+
#if !defined(USE_WINDOWS_API) && defined(O_NOFOLLOW)
733+
WFD fd = -1;
734+
#endif
735+
736+
if (f != NULL)
737+
*f = NULL;
738+
if (f == NULL || path == NULL)
739+
ret = 1;
740+
741+
#if !defined(USE_WINDOWS_API) && defined(O_NOFOLLOW)
742+
if (ret == 0) {
743+
fd = WOPEN(fs, path, WOLFSSH_O_RDONLY | WOLFSSH_O_NOFOLLOW, 0);
744+
if (fd < 0)
745+
ret = 1;
746+
}
747+
if (ret == 0 && WFDOPEN(fs, f, fd, "rb") != 0) {
748+
WCLOSE(fs, fd);
749+
ret = 1;
750+
}
751+
#else
752+
if (ret == 0 && wIsSymlink(path))
753+
ret = 1;
754+
if (ret == 0 && WFOPEN(fs, f, path, "rb") != 0)
755+
ret = 1;
756+
#endif
757+
WOLFSSH_UNUSED(fs);
758+
return ret;
759+
}
760+
761+
#if !defined(USE_WINDOWS_API) && !defined(NO_WOLFSSH_DIR)
762+
/* Open a directory without following a final-component symlink: POSIX uses
763+
* O_DIRECTORY|O_NOFOLLOW + fdopendir (atomic), else falls back to wIsSymlink +
764+
* opendir. Returns 0 on success, non-zero on failure (matches WOPENDIR). */
765+
int wOpendirNoFollow(void* fs, WDIR* dir, const char* path)
766+
{
767+
int ret = 0;
768+
#if defined(O_NOFOLLOW) && defined(O_DIRECTORY)
769+
WFD fd = -1;
770+
#endif
771+
772+
if (dir != NULL)
773+
*dir = NULL;
774+
if (dir == NULL || path == NULL)
775+
ret = 1;
776+
777+
#if defined(O_NOFOLLOW) && defined(O_DIRECTORY)
778+
if (ret == 0) {
779+
fd = WOPEN(fs, path, WOLFSSH_O_RDONLY | WOLFSSH_O_DIRECTORY |
780+
WOLFSSH_O_NOFOLLOW, 0);
781+
if (fd < 0)
782+
ret = 1;
783+
}
784+
if (ret == 0 && WFDOPENDIR(fs, dir, fd) != 0) {
785+
WCLOSE(fs, fd);
786+
ret = 1;
787+
}
788+
#else
789+
if (ret == 0 && wIsSymlink(path))
790+
ret = 1;
791+
if (ret == 0 && WOPENDIR(fs, NULL, dir, path) != 0)
792+
ret = 1;
793+
#endif
794+
WOLFSSH_UNUSED(fs);
795+
return ret;
796+
}
797+
#endif /* !USE_WINDOWS_API && !NO_WOLFSSH_DIR */
722798
#endif /* WOLFSSH_HAVE_SYMLINK && (WOLFSSH_SFTP || WOLFSSH_SCP) */
723799

724800
#ifndef WSTRING_USER

src/wolfscp.c

Lines changed: 122 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -646,6 +646,16 @@ int DoScpSource(WOLFSSH* ssh)
646646
continue;
647647

648648
} else if (ssh->scpConfirm == WS_SCP_ABORT) {
649+
#if !defined(NO_FILESYSTEM) && \
650+
!defined(WOLFSSH_SCP_USER_CALLBACKS)
651+
/* drain any partial recursive dir stack so a later exec on
652+
* this connection starts from a fresh root, not a stale
653+
* handle left by the aborted walk */
654+
ScpSendCtx* sendCtx =
655+
(ScpSendCtx*)wolfSSH_GetScpSendCtx(ssh);
656+
if (sendCtx != NULL)
657+
ScpSendCtxFreeDirs(ssh->fs, sendCtx, ssh->ctx->heap);
658+
#endif
649659
ssh->scpState = SCP_SEND_CONFIRMATION;
650660
ssh->scpNextState = SCP_DONE;
651661
continue;
@@ -2454,8 +2464,15 @@ static int GetFileStats(void *fs, ScpSendCtx* ctx, const char* fileName,
24542464
*fileMode = 0555 |
24552465
(ctx->s.dwFileAttributes & FILE_ATTRIBUTE_READONLY ? 0 : 0200);
24562466
*fileMode |= (ctx->s.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) ? 040000 : 0;
2467+
#else
2468+
/* WLSTAT (lstat on POSIX) leaves a symlink unfollowed, so it classifies as
2469+
* neither dir nor file and is skipped; WOLFSSH_NO_SYMLINK_CHECK falls back
2470+
* to WSTAT so links are followed by design. */
2471+
#ifdef WOLFSSH_HAVE_SYMLINK
2472+
if (WLSTAT(fs, fileName, &ctx->s) < 0) {
24572473
#else
24582474
if (WSTAT(fs, fileName, &ctx->s) < 0) {
2475+
#endif
24592476
ret = WS_BAD_FILE_E;
24602477
#ifdef WOLFSSL_NUCLEUS
24612478
if (WSTRLEN(fileName) < 4 && WSTRLEN(fileName) > 2 &&
@@ -2543,11 +2560,16 @@ static ScpDir* ScpNewDir(void *fs, const char* path, void* heap)
25432560
}
25442561
}
25452562
#else
2563+
#ifdef WOLFSSH_HAVE_SYMLINK
2564+
/* refuse a symlinked directory leaf atomically (closes the descend race) */
2565+
if (wOpendirNoFollow(fs, &entry->dir, path) != 0) {
2566+
#else
25462567
if (WOPENDIR(fs, heap, &entry->dir, path) != 0
25472568
#if !defined(WOLFSSL_NUCLEUS) && !defined(WOLFSSH_ZEPHYR)
25482569
|| entry->dir == NULL
25492570
#endif
25502571
) {
2572+
#endif
25512573
WFREE(entry, heap, DYNTYPE_SCPDIR);
25522574
WLOG(WS_LOG_ERROR, scpError, "opendir failed on directory",
25532575
WS_INVALID_PATH_E);
@@ -2626,6 +2648,17 @@ int ScpPopDir(void *fs, ScpSendCtx* ctx, void* heap)
26262648
return WS_SUCCESS;
26272649
}
26282650

2651+
/* Drain dir-stack entries (and open dir handles) left on a send context after
2652+
* a recursive transfer aborts mid-tree before popping. Safe on an empty
2653+
* stack. */
2654+
void ScpSendCtxFreeDirs(void* fs, ScpSendCtx* ctx, void* heap)
2655+
{
2656+
if (ctx != NULL) {
2657+
while (ctx->currentDir != NULL)
2658+
(void)ScpPopDir(fs, ctx, heap);
2659+
}
2660+
}
2661+
26292662
/* Get next entry in directory, either file or directory, skips self (.)
26302663
* and parent (..) directories, stores in ctx->entry.
26312664
* Return WS_SUCCESS on success or negative upon error */
@@ -2820,6 +2853,16 @@ static int ScpProcessEntry(WOLFSSH* ssh, char* fileName, word64* mTime,
28202853
WSTRNCPY(fileName, sendCtx->entry->d_name,
28212854
DEFAULT_SCP_FILE_NAME_SZ);
28222855
#endif
2856+
#ifdef WOLFSSH_HAVE_SYMLINK
2857+
/* filePath is fully built; reject a planted symlink before
2858+
* GetFileStats or any descend/open follows it. */
2859+
if (ret == WS_SUCCESS && wIsSymlink(filePath)) {
2860+
WLOG(WS_LOG_ERROR,
2861+
"scp: symlink entry rejected, aborting transfer");
2862+
ret = WS_SCP_ABORT;
2863+
}
2864+
#endif /* WOLFSSH_HAVE_SYMLINK */
2865+
28232866
if (ret == WS_SUCCESS) {
28242867
ret = GetFileStats(ssh->fs, sendCtx, filePath, mTime, aTime, fileMode);
28252868
}
@@ -2839,7 +2882,11 @@ static int ScpProcessEntry(WOLFSSH* ssh, char* fileName, word64* mTime,
28392882
}
28402883

28412884
} else if (ScpFileIsFile(sendCtx)) {
2885+
#ifdef WOLFSSH_HAVE_SYMLINK
2886+
if (wFopenNoFollow(ssh->fs, &(sendCtx->fp), filePath) != 0) {
2887+
#else
28422888
if (WFOPEN(ssh->fs, &(sendCtx->fp), filePath, "rb") != 0) {
2889+
#endif
28432890
WLOG(WS_LOG_ERROR, "scp: Error with opening file, abort");
28442891
wolfSSH_SetScpErrorMsg(ssh, "unable to open file "
28452892
"for reading");
@@ -2862,7 +2909,10 @@ static int ScpProcessEntry(WOLFSSH* ssh, char* fileName, word64* mTime,
28622909
}
28632910

28642911
} else {
2865-
if (ret != WS_NEXT_ERROR) {
2912+
/* WS_SCP_ABORT entries (e.g. a rejected symlink) were already logged at
2913+
* their source, so only the generic, unexpected-error case is noted
2914+
* here to avoid a misleading second log line. */
2915+
if (ret != WS_NEXT_ERROR && ret != WS_SCP_ABORT) {
28662916
WLOG(WS_LOG_ERROR, "scp: ret does not equal WS_NEXT_ERROR, abort");
28672917
ret = WS_SCP_ABORT;
28682918
}
@@ -2948,6 +2998,22 @@ static int ScpProcessEntry(WOLFSSH* ssh, char* fileName, word64* mTime,
29482998
* is complete.
29492999
* WS_SCP_ABORT - abort file transfer request
29503000
* WS_BAD_FILE_E - local file open error hit
3001+
*
3002+
* Symlink handling: file-content opens go through wFopenNoFollow and directory
3003+
* opens (both the recursive root and every descend) go through wOpendirNoFollow.
3004+
* Both are atomic against a swapped link on POSIX (O_NOFOLLOW, plus O_DIRECTORY
3005+
* for the directory open) and fall back to a wIsSymlink check-then-open on
3006+
* Windows or where O_NOFOLLOW is absent. The root also gets an explicit
3007+
* wIsSymlink pre-check because a trailing separator (open("link/", O_NOFOLLOW))
3008+
* can still follow the link; symlinks below the root are rejected as
3009+
* ScpProcessEntry traverses them. No "stays under a trusted base" containment
3010+
* is attempted: SCP has no library-level base path (wolfsshd relies on OS
3011+
* chroot) and wolfSSH_RealPath does not resolve links. GetFileStats uses WLSTAT
3012+
* so it does not follow a link for metadata or classification. On the
3013+
* Windows/fallback path the open is check-then-open, so a concurrent in-jail
3014+
* writer racing it remains a best-effort gap. For hostile multi-tenant use,
3015+
* confine the session with an OS mechanism (chroot, dropped privileges) and
3016+
* treat these checks as defense in depth.
29513017
*/
29523018
int wsScpSendCallback(WOLFSSH* ssh, int state, const char* peerRequest,
29533019
char* fileName, word32 fileNameSz, word64* mTime, word64* aTime,
@@ -2981,9 +3047,14 @@ int wsScpSendCallback(WOLFSSH* ssh, int state, const char* peerRequest,
29813047
break;
29823048

29833049
case WOLFSSH_SCP_SINGLE_FILE_REQUEST:
2984-
if ((sendCtx == NULL) || WFOPEN(ssh->fs, &(sendCtx->fp), peerRequest,
2985-
"rb") != 0) {
2986-
3050+
/* open without following a symlink so its target is not streamed
3051+
* to the peer; see this function's symlink-handling note. */
3052+
if ((sendCtx == NULL) ||
3053+
#ifdef WOLFSSH_HAVE_SYMLINK
3054+
wFopenNoFollow(ssh->fs, &(sendCtx->fp), peerRequest) != 0) {
3055+
#else
3056+
WFOPEN(ssh->fs, &(sendCtx->fp), peerRequest, "rb") != 0) {
3057+
#endif
29873058
WLOG(WS_LOG_ERROR, "scp: unable to open file, abort");
29883059
wolfSSH_SetScpErrorMsg(ssh, "unable to open file for reading");
29893060
ret = WS_BAD_FILE_E;
@@ -3037,9 +3108,51 @@ int wsScpSendCallback(WOLFSSH* ssh, int state, const char* peerRequest,
30373108
case WOLFSSH_SCP_RECURSIVE_REQUEST:
30383109

30393110
if (ScpDirStackIsEmpty(sendCtx)) {
3111+
#ifdef WOLFSSH_HAVE_SYMLINK
3112+
word32 rootLen;
3113+
#endif
3114+
3115+
/* first request, keep track of request directory. Reject a
3116+
* symlink root here (a trailing separator can still follow);
3117+
* see the symlink-handling note in this function's header. */
3118+
ret = WS_SUCCESS;
3119+
if (peerRequest == NULL) {
3120+
WLOG(WS_LOG_ERROR,
3121+
"scp: missing recursive root path, abort");
3122+
ret = WS_SCP_ABORT;
3123+
}
3124+
#ifdef WOLFSSH_HAVE_SYMLINK
3125+
/* lstat() follows the link when the path ends in a separator,
3126+
* so check the root with any trailing separators removed */
3127+
else {
3128+
rootLen = (word32)WSTRLEN(peerRequest);
3129+
while (rootLen > 1 && (peerRequest[rootLen - 1] == '/' ||
3130+
peerRequest[rootLen - 1] == '\\'))
3131+
rootLen--;
3132+
if (rootLen >= DEFAULT_SCP_FILE_NAME_SZ) {
3133+
WLOG(WS_LOG_ERROR,
3134+
"scp: recursive root path too long, abort");
3135+
wolfSSH_SetScpErrorMsg(ssh,
3136+
"unable to open file for reading");
3137+
ret = WS_SCP_ABORT;
3138+
}
3139+
else {
3140+
WMEMCPY(filePath, peerRequest, rootLen);
3141+
filePath[rootLen] = '\0';
3142+
if (wIsSymlink(filePath)) {
3143+
WLOG(WS_LOG_ERROR,
3144+
"scp: refusing recursive root symlink, abort");
3145+
wolfSSH_SetScpErrorMsg(ssh,
3146+
"unable to open file for reading");
3147+
ret = WS_SCP_ABORT;
3148+
}
3149+
}
3150+
}
3151+
#endif /* WOLFSSH_HAVE_SYMLINK */
30403152

3041-
/* first request, keep track of request directory */
3042-
ret = ScpPushDir(ssh->fs, sendCtx, peerRequest, ssh->ctx->heap);
3153+
if (ret == WS_SUCCESS)
3154+
ret = ScpPushDir(ssh->fs, sendCtx, peerRequest,
3155+
ssh->ctx->heap);
30433156

30443157
if (ret == WS_SUCCESS) {
30453158
/* get file name from request */
@@ -3053,7 +3166,9 @@ int wsScpSendCallback(WOLFSSH* ssh, int state, const char* peerRequest,
30533166

30543167
if (ret == WS_SUCCESS) {
30553168
ret = WS_SCP_ENTER_DIR;
3056-
} else {
3169+
} else if (ret != WS_SCP_ABORT) {
3170+
/* a rejected symlink root already logged its own reason;
3171+
* only note the generic stat failure here */
30573172
WLOG(WS_LOG_ERROR, "scp: error getting file stats, abort");
30583173
ret = WS_SCP_ABORT;
30593174
}

0 commit comments

Comments
 (0)