Skip to content

Commit 75cced7

Browse files
Reject symlinks in default SCP send callback
1 parent 52f6db9 commit 75cced7

8 files changed

Lines changed: 413 additions & 12 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(" WOLFSSH_NO_SYMLINK_CHECK\n");
136+
#endif
132137
}
133138

134139

scripts/scp.test

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

155210
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+
#ifndef USE_WINDOWS_API
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 */
722798
#endif /* WOLFSSH_HAVE_SYMLINK && (WOLFSSH_SFTP || WOLFSSH_SCP) */
723799

724800
#ifndef WSTRING_USER

src/wolfscp.c

Lines changed: 98 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2455,7 +2455,10 @@ static int GetFileStats(void *fs, ScpSendCtx* ctx, const char* fileName,
24552455
(ctx->s.dwFileAttributes & FILE_ATTRIBUTE_READONLY ? 0 : 0200);
24562456
*fileMode |= (ctx->s.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) ? 040000 : 0;
24572457
#else
2458-
if (WSTAT(fs, fileName, &ctx->s) < 0) {
2458+
/* WLSTAT (lstat on POSIX) does not follow a symlink, so the metadata and
2459+
* the dir/file classification below reflect the link itself, not its
2460+
* target; a symlink is then neither dir nor file and is not traversed. */
2461+
if (WLSTAT(fs, fileName, &ctx->s) < 0) {
24592462
ret = WS_BAD_FILE_E;
24602463
#ifdef WOLFSSL_NUCLEUS
24612464
if (WSTRLEN(fileName) < 4 && WSTRLEN(fileName) > 2 &&
@@ -2543,11 +2546,16 @@ static ScpDir* ScpNewDir(void *fs, const char* path, void* heap)
25432546
}
25442547
}
25452548
#else
2549+
#ifdef WOLFSSH_HAVE_SYMLINK
2550+
/* refuse a symlinked directory leaf atomically (closes the descend race) */
2551+
if (wOpendirNoFollow(fs, &entry->dir, path) != 0) {
2552+
#else
25462553
if (WOPENDIR(fs, heap, &entry->dir, path) != 0
25472554
#if !defined(WOLFSSL_NUCLEUS) && !defined(WOLFSSH_ZEPHYR)
25482555
|| entry->dir == NULL
25492556
#endif
25502557
) {
2558+
#endif
25512559
WFREE(entry, heap, DYNTYPE_SCPDIR);
25522560
WLOG(WS_LOG_ERROR, scpError, "opendir failed on directory",
25532561
WS_INVALID_PATH_E);
@@ -2626,6 +2634,17 @@ int ScpPopDir(void *fs, ScpSendCtx* ctx, void* heap)
26262634
return WS_SUCCESS;
26272635
}
26282636

2637+
/* Drain dir-stack entries (and open dir handles) left on a send context after
2638+
* a recursive transfer aborts mid-tree before popping. Safe on an empty
2639+
* stack. */
2640+
void ScpSendCtxFreeDirs(void* fs, ScpSendCtx* ctx, void* heap)
2641+
{
2642+
if (ctx != NULL) {
2643+
while (ctx->currentDir != NULL)
2644+
(void)ScpPopDir(fs, ctx, heap);
2645+
}
2646+
}
2647+
26292648
/* Get next entry in directory, either file or directory, skips self (.)
26302649
* and parent (..) directories, stores in ctx->entry.
26312650
* Return WS_SUCCESS on success or negative upon error */
@@ -2820,6 +2839,16 @@ static int ScpProcessEntry(WOLFSSH* ssh, char* fileName, word64* mTime,
28202839
WSTRNCPY(fileName, sendCtx->entry->d_name,
28212840
DEFAULT_SCP_FILE_NAME_SZ);
28222841
#endif
2842+
#ifdef WOLFSSH_HAVE_SYMLINK
2843+
/* filePath is fully built; reject a planted symlink before
2844+
* GetFileStats or any descend/open follows it. */
2845+
if (ret == WS_SUCCESS && wIsSymlink(filePath)) {
2846+
WLOG(WS_LOG_ERROR,
2847+
"scp: symlink entry rejected, aborting transfer");
2848+
ret = WS_SCP_ABORT;
2849+
}
2850+
#endif /* WOLFSSH_HAVE_SYMLINK */
2851+
28232852
if (ret == WS_SUCCESS) {
28242853
ret = GetFileStats(ssh->fs, sendCtx, filePath, mTime, aTime, fileMode);
28252854
}
@@ -2839,7 +2868,11 @@ static int ScpProcessEntry(WOLFSSH* ssh, char* fileName, word64* mTime,
28392868
}
28402869

28412870
} else if (ScpFileIsFile(sendCtx)) {
2871+
#ifdef WOLFSSH_HAVE_SYMLINK
2872+
if (wFopenNoFollow(ssh->fs, &(sendCtx->fp), filePath) != 0) {
2873+
#else
28422874
if (WFOPEN(ssh->fs, &(sendCtx->fp), filePath, "rb") != 0) {
2875+
#endif
28432876
WLOG(WS_LOG_ERROR, "scp: Error with opening file, abort");
28442877
wolfSSH_SetScpErrorMsg(ssh, "unable to open file "
28452878
"for reading");
@@ -2862,7 +2895,10 @@ static int ScpProcessEntry(WOLFSSH* ssh, char* fileName, word64* mTime,
28622895
}
28632896

28642897
} else {
2865-
if (ret != WS_NEXT_ERROR) {
2898+
/* WS_SCP_ABORT entries (e.g. a rejected symlink) were already logged at
2899+
* their source, so only the generic, unexpected-error case is noted
2900+
* here to avoid a misleading second log line. */
2901+
if (ret != WS_NEXT_ERROR && ret != WS_SCP_ABORT) {
28662902
WLOG(WS_LOG_ERROR, "scp: ret does not equal WS_NEXT_ERROR, abort");
28672903
ret = WS_SCP_ABORT;
28682904
}
@@ -2948,6 +2984,22 @@ static int ScpProcessEntry(WOLFSSH* ssh, char* fileName, word64* mTime,
29482984
* is complete.
29492985
* WS_SCP_ABORT - abort file transfer request
29502986
* WS_BAD_FILE_E - local file open error hit
2987+
*
2988+
* Symlink handling: file-content opens go through wFopenNoFollow and directory
2989+
* opens (both the recursive root and every descend) go through wOpendirNoFollow.
2990+
* Both are atomic against a swapped link on POSIX (O_NOFOLLOW, plus O_DIRECTORY
2991+
* for the directory open) and fall back to a wIsSymlink check-then-open on
2992+
* Windows or where O_NOFOLLOW is absent. The root also gets an explicit
2993+
* wIsSymlink pre-check because a trailing separator (open("link/", O_NOFOLLOW))
2994+
* can still follow the link; symlinks below the root are rejected as
2995+
* ScpProcessEntry traverses them. No "stays under a trusted base" containment
2996+
* is attempted: SCP has no library-level base path (wolfsshd relies on OS
2997+
* chroot) and wolfSSH_RealPath does not resolve links. GetFileStats uses WLSTAT
2998+
* so it does not follow a link for metadata or classification. On the
2999+
* Windows/fallback path the open is check-then-open, so a concurrent in-jail
3000+
* writer racing it remains a best-effort gap. For hostile multi-tenant use,
3001+
* confine the session with an OS mechanism (chroot, dropped privileges) and
3002+
* treat these checks as defense in depth.
29513003
*/
29523004
int wsScpSendCallback(WOLFSSH* ssh, int state, const char* peerRequest,
29533005
char* fileName, word32 fileNameSz, word64* mTime, word64* aTime,
@@ -2981,9 +3033,14 @@ int wsScpSendCallback(WOLFSSH* ssh, int state, const char* peerRequest,
29813033
break;
29823034

29833035
case WOLFSSH_SCP_SINGLE_FILE_REQUEST:
2984-
if ((sendCtx == NULL) || WFOPEN(ssh->fs, &(sendCtx->fp), peerRequest,
2985-
"rb") != 0) {
2986-
3036+
/* open without following a symlink so its target is not streamed
3037+
* to the peer; see this function's symlink-handling note. */
3038+
if ((sendCtx == NULL) ||
3039+
#ifdef WOLFSSH_HAVE_SYMLINK
3040+
wFopenNoFollow(ssh->fs, &(sendCtx->fp), peerRequest) != 0) {
3041+
#else
3042+
WFOPEN(ssh->fs, &(sendCtx->fp), peerRequest, "rb") != 0) {
3043+
#endif
29873044
WLOG(WS_LOG_ERROR, "scp: unable to open file, abort");
29883045
wolfSSH_SetScpErrorMsg(ssh, "unable to open file for reading");
29893046
ret = WS_BAD_FILE_E;
@@ -3037,9 +3094,40 @@ int wsScpSendCallback(WOLFSSH* ssh, int state, const char* peerRequest,
30373094
case WOLFSSH_SCP_RECURSIVE_REQUEST:
30383095

30393096
if (ScpDirStackIsEmpty(sendCtx)) {
3097+
#ifdef WOLFSSH_HAVE_SYMLINK
3098+
word32 rootLen;
3099+
#endif
30403100

3041-
/* first request, keep track of request directory */
3042-
ret = ScpPushDir(ssh->fs, sendCtx, peerRequest, ssh->ctx->heap);
3101+
/* first request, keep track of request directory. Reject a
3102+
* symlink root here (a trailing separator can still follow);
3103+
* see the symlink-handling note in this function's header. */
3104+
ret = WS_SUCCESS;
3105+
#ifdef WOLFSSH_HAVE_SYMLINK
3106+
/* lstat() follows the link when the path ends in a separator,
3107+
* so check the root with any trailing separators removed */
3108+
rootLen = (word32)WSTRLEN(peerRequest);
3109+
while (rootLen > 1 && (peerRequest[rootLen - 1] == '/' ||
3110+
peerRequest[rootLen - 1] == '\\'))
3111+
rootLen--;
3112+
if (rootLen >= DEFAULT_SCP_FILE_NAME_SZ) {
3113+
ret = WS_SCP_ABORT;
3114+
}
3115+
else {
3116+
WMEMCPY(filePath, peerRequest, rootLen);
3117+
filePath[rootLen] = '\0';
3118+
if (wIsSymlink(filePath)) {
3119+
WLOG(WS_LOG_ERROR,
3120+
"scp: refusing recursive root symlink, abort");
3121+
wolfSSH_SetScpErrorMsg(ssh,
3122+
"unable to open file for reading");
3123+
ret = WS_SCP_ABORT;
3124+
}
3125+
}
3126+
#endif /* WOLFSSH_HAVE_SYMLINK */
3127+
3128+
if (ret == WS_SUCCESS)
3129+
ret = ScpPushDir(ssh->fs, sendCtx, peerRequest,
3130+
ssh->ctx->heap);
30433131

30443132
if (ret == WS_SUCCESS) {
30453133
/* get file name from request */
@@ -3053,7 +3141,9 @@ int wsScpSendCallback(WOLFSSH* ssh, int state, const char* peerRequest,
30533141

30543142
if (ret == WS_SUCCESS) {
30553143
ret = WS_SCP_ENTER_DIR;
3056-
} else {
3144+
} else if (ret != WS_SCP_ABORT) {
3145+
/* a rejected symlink root already logged its own reason;
3146+
* only note the generic stat failure here */
30573147
WLOG(WS_LOG_ERROR, "scp: error getting file stats, abort");
30583148
ret = WS_SCP_ABORT;
30593149
}

0 commit comments

Comments
 (0)