Skip to content

Commit e1dede6

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

7 files changed

Lines changed: 334 additions & 12 deletions

File tree

scripts/scp.test

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

155201
exit 0

src/internal.c

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1399,6 +1399,10 @@ void SshResourceFree(WOLFSSH* ssh, void* heap)
13991399
ssh->scpBasePathDynamic = NULL;
14001400
ssh->scpBasePathSz = 0;
14011401
}
1402+
#if !defined(WOLFSSH_SCP_USER_CALLBACKS) && !defined(NO_FILESYSTEM)
1403+
/* free any send-side directory stack left by an aborted recursive transfer */
1404+
ScpSendCtxFreeDirs(ssh->fs, &ssh->scpSendCbCtx, heap);
1405+
#endif
14021406
#endif
14031407
#ifdef WOLFSSH_SFTP
14041408
if (ssh->sftpDefaultPath) {

src/port.c

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -719,6 +719,44 @@ 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+
}
722760
#endif /* WOLFSSH_HAVE_SYMLINK && (WOLFSSH_SFTP || WOLFSSH_SCP) */
723761

724762
#ifndef WSTRING_USER

src/wolfscp.c

Lines changed: 92 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 &&
@@ -2626,6 +2629,17 @@ int ScpPopDir(void *fs, ScpSendCtx* ctx, void* heap)
26262629
return WS_SUCCESS;
26272630
}
26282631

2632+
/* Drain dir-stack entries (and open dir handles) left on a send context after
2633+
* a recursive transfer aborts mid-tree before popping. Safe on an empty
2634+
* stack. */
2635+
void ScpSendCtxFreeDirs(void* fs, ScpSendCtx* ctx, void* heap)
2636+
{
2637+
if (ctx != NULL) {
2638+
while (ctx->currentDir != NULL)
2639+
(void)ScpPopDir(fs, ctx, heap);
2640+
}
2641+
}
2642+
26292643
/* Get next entry in directory, either file or directory, skips self (.)
26302644
* and parent (..) directories, stores in ctx->entry.
26312645
* Return WS_SUCCESS on success or negative upon error */
@@ -2820,6 +2834,16 @@ static int ScpProcessEntry(WOLFSSH* ssh, char* fileName, word64* mTime,
28202834
WSTRNCPY(fileName, sendCtx->entry->d_name,
28212835
DEFAULT_SCP_FILE_NAME_SZ);
28222836
#endif
2837+
#ifdef WOLFSSH_HAVE_SYMLINK
2838+
/* filePath is fully built; reject a planted symlink before
2839+
* GetFileStats or any descend/open follows it. */
2840+
if (ret == WS_SUCCESS && wIsSymlink(filePath)) {
2841+
WLOG(WS_LOG_ERROR,
2842+
"scp: symlink entry rejected, aborting transfer");
2843+
ret = WS_SCP_ABORT;
2844+
}
2845+
#endif /* WOLFSSH_HAVE_SYMLINK */
2846+
28232847
if (ret == WS_SUCCESS) {
28242848
ret = GetFileStats(ssh->fs, sendCtx, filePath, mTime, aTime, fileMode);
28252849
}
@@ -2839,7 +2863,11 @@ static int ScpProcessEntry(WOLFSSH* ssh, char* fileName, word64* mTime,
28392863
}
28402864

28412865
} else if (ScpFileIsFile(sendCtx)) {
2866+
#ifdef WOLFSSH_HAVE_SYMLINK
2867+
if (wFopenNoFollow(ssh->fs, &(sendCtx->fp), filePath) != 0) {
2868+
#else
28422869
if (WFOPEN(ssh->fs, &(sendCtx->fp), filePath, "rb") != 0) {
2870+
#endif
28432871
WLOG(WS_LOG_ERROR, "scp: Error with opening file, abort");
28442872
wolfSSH_SetScpErrorMsg(ssh, "unable to open file "
28452873
"for reading");
@@ -2862,7 +2890,10 @@ static int ScpProcessEntry(WOLFSSH* ssh, char* fileName, word64* mTime,
28622890
}
28632891

28642892
} else {
2865-
if (ret != WS_NEXT_ERROR) {
2893+
/* WS_SCP_ABORT entries (e.g. a rejected symlink) were already logged at
2894+
* their source, so only the generic, unexpected-error case is noted
2895+
* here to avoid a misleading second log line. */
2896+
if (ret != WS_NEXT_ERROR && ret != WS_SCP_ABORT) {
28662897
WLOG(WS_LOG_ERROR, "scp: ret does not equal WS_NEXT_ERROR, abort");
28672898
ret = WS_SCP_ABORT;
28682899
}
@@ -2948,6 +2979,21 @@ static int ScpProcessEntry(WOLFSSH* ssh, char* fileName, word64* mTime,
29482979
* is complete.
29492980
* WS_SCP_ABORT - abort file transfer request
29502981
* WS_BAD_FILE_E - local file open error hit
2982+
*
2983+
* Symlink handling: file-content opens go through wFopenNoFollow, atomic
2984+
* against a swapped link on POSIX (O_NOFOLLOW) and falling back to a wIsSymlink
2985+
* check-then-open on Windows or where O_NOFOLLOW is absent. The recursive root
2986+
* is opened with opendir (which follows links), so it is leaf-checked with
2987+
* wIsSymlink instead; symlinks below the root are rejected as ScpProcessEntry
2988+
* traverses them. No "stays under a trusted base" containment is attempted:
2989+
* SCP has no library-level base path (wolfsshd relies on OS chroot) and
2990+
* wolfSSH_RealPath does not resolve links. GetFileStats uses WLSTAT so it does
2991+
* not follow a link for metadata or classification, but the directory descend
2992+
* still opens with opendir (which follows), and the Windows/fallback open is
2993+
* check-then-open, so a concurrent in-jail writer racing the descend remains a
2994+
* best-effort gap. For hostile multi-tenant use, confine the session with an OS
2995+
* mechanism (chroot, dropped privileges) and treat these checks as defense in
2996+
* depth.
29512997
*/
29522998
int wsScpSendCallback(WOLFSSH* ssh, int state, const char* peerRequest,
29532999
char* fileName, word32 fileNameSz, word64* mTime, word64* aTime,
@@ -2981,9 +3027,14 @@ int wsScpSendCallback(WOLFSSH* ssh, int state, const char* peerRequest,
29813027
break;
29823028

29833029
case WOLFSSH_SCP_SINGLE_FILE_REQUEST:
2984-
if ((sendCtx == NULL) || WFOPEN(ssh->fs, &(sendCtx->fp), peerRequest,
2985-
"rb") != 0) {
2986-
3030+
/* open without following a symlink so its target is not streamed
3031+
* to the peer; see this function's symlink-handling note. */
3032+
if ((sendCtx == NULL) ||
3033+
#ifdef WOLFSSH_HAVE_SYMLINK
3034+
wFopenNoFollow(ssh->fs, &(sendCtx->fp), peerRequest) != 0) {
3035+
#else
3036+
WFOPEN(ssh->fs, &(sendCtx->fp), peerRequest, "rb") != 0) {
3037+
#endif
29873038
WLOG(WS_LOG_ERROR, "scp: unable to open file, abort");
29883039
wolfSSH_SetScpErrorMsg(ssh, "unable to open file for reading");
29893040
ret = WS_BAD_FILE_E;
@@ -3037,9 +3088,40 @@ int wsScpSendCallback(WOLFSSH* ssh, int state, const char* peerRequest,
30373088
case WOLFSSH_SCP_RECURSIVE_REQUEST:
30383089

30393090
if (ScpDirStackIsEmpty(sendCtx)) {
3091+
#ifdef WOLFSSH_HAVE_SYMLINK
3092+
word32 rootLen;
3093+
#endif
3094+
3095+
/* first request, keep track of request directory. Reject a
3096+
* symlink root here (opendir would follow it); see the
3097+
* symlink-handling note in this function's header. */
3098+
ret = WS_SUCCESS;
3099+
#ifdef WOLFSSH_HAVE_SYMLINK
3100+
/* lstat() follows the link when the path ends in a separator,
3101+
* so check the root with any trailing separators removed */
3102+
rootLen = (word32)WSTRLEN(peerRequest);
3103+
while (rootLen > 1 && (peerRequest[rootLen - 1] == '/' ||
3104+
peerRequest[rootLen - 1] == '\\'))
3105+
rootLen--;
3106+
if (rootLen >= DEFAULT_SCP_FILE_NAME_SZ) {
3107+
ret = WS_SCP_ABORT;
3108+
}
3109+
else {
3110+
WMEMCPY(filePath, peerRequest, rootLen);
3111+
filePath[rootLen] = '\0';
3112+
if (wIsSymlink(filePath)) {
3113+
WLOG(WS_LOG_ERROR,
3114+
"scp: refusing recursive root symlink, abort");
3115+
wolfSSH_SetScpErrorMsg(ssh,
3116+
"unable to open file for reading");
3117+
ret = WS_SCP_ABORT;
3118+
}
3119+
}
3120+
#endif /* WOLFSSH_HAVE_SYMLINK */
30403121

3041-
/* first request, keep track of request directory */
3042-
ret = ScpPushDir(ssh->fs, sendCtx, peerRequest, ssh->ctx->heap);
3122+
if (ret == WS_SUCCESS)
3123+
ret = ScpPushDir(ssh->fs, sendCtx, peerRequest,
3124+
ssh->ctx->heap);
30433125

30443126
if (ret == WS_SUCCESS) {
30453127
/* get file name from request */
@@ -3053,7 +3135,9 @@ int wsScpSendCallback(WOLFSSH* ssh, int state, const char* peerRequest,
30533135

30543136
if (ret == WS_SUCCESS) {
30553137
ret = WS_SCP_ENTER_DIR;
3056-
} else {
3138+
} else if (ret != WS_SCP_ABORT) {
3139+
/* a rejected symlink root already logged its own reason;
3140+
* only note the generic stat failure here */
30573141
WLOG(WS_LOG_ERROR, "scp: error getting file stats, abort");
30583142
ret = WS_SCP_ABORT;
30593143
}

0 commit comments

Comments
 (0)