Skip to content

Commit 4f9761c

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

5 files changed

Lines changed: 253 additions & 8 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/port.c

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -719,6 +719,43 @@ 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(WFILE** f, const char* path)
730+
{
731+
int ret = 0;
732+
#if !defined(USE_WINDOWS_API) && defined(O_NOFOLLOW)
733+
WFD fd;
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(NULL, path, WOLFSSH_O_RDONLY | WOLFSSH_O_NOFOLLOW, 0);
744+
if (fd < 0)
745+
ret = 1;
746+
}
747+
if (ret == 0 && WFDOPEN(NULL, f, fd, "rb") != 0) {
748+
WCLOSE(NULL, fd);
749+
ret = 1;
750+
}
751+
#else
752+
if (ret == 0 && wIsSymlink(path))
753+
ret = 1;
754+
if (ret == 0 && WFOPEN(NULL, f, path, "rb") != 0)
755+
ret = 1;
756+
#endif
757+
return ret;
758+
}
722759
#endif /* WOLFSSH_HAVE_SYMLINK && (WOLFSSH_SFTP || WOLFSSH_SCP) */
723760

724761
#ifndef WSTRING_USER

src/wolfscp.c

Lines changed: 60 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2820,6 +2820,16 @@ 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 fully built; reject a planted symlink before
2825+
* GetFileStats or any descend/open follows it. */
2826+
if (ret == WS_SUCCESS && wIsSymlink(filePath)) {
2827+
WLOG(WS_LOG_ERROR,
2828+
"scp: symlink entry rejected, aborting transfer");
2829+
ret = WS_SCP_ABORT;
2830+
}
2831+
#endif /* WOLFSSH_HAVE_SYMLINK */
2832+
28232833
if (ret == WS_SUCCESS) {
28242834
ret = GetFileStats(ssh->fs, sendCtx, filePath, mTime, aTime, fileMode);
28252835
}
@@ -2839,7 +2849,11 @@ static int ScpProcessEntry(WOLFSSH* ssh, char* fileName, word64* mTime,
28392849
}
28402850

28412851
} else if (ScpFileIsFile(sendCtx)) {
2852+
#ifdef WOLFSSH_HAVE_SYMLINK
2853+
if (wFopenNoFollow(&(sendCtx->fp), filePath) != 0) {
2854+
#else
28422855
if (WFOPEN(ssh->fs, &(sendCtx->fp), filePath, "rb") != 0) {
2856+
#endif
28432857
WLOG(WS_LOG_ERROR, "scp: Error with opening file, abort");
28442858
wolfSSH_SetScpErrorMsg(ssh, "unable to open file "
28452859
"for reading");
@@ -2862,7 +2876,10 @@ static int ScpProcessEntry(WOLFSSH* ssh, char* fileName, word64* mTime,
28622876
}
28632877

28642878
} else {
2865-
if (ret != WS_NEXT_ERROR) {
2879+
/* WS_SCP_ABORT entries (e.g. a rejected symlink) were already logged at
2880+
* their source, so only the generic, unexpected-error case is noted
2881+
* here to avoid a misleading second log line. */
2882+
if (ret != WS_NEXT_ERROR && ret != WS_SCP_ABORT) {
28662883
WLOG(WS_LOG_ERROR, "scp: ret does not equal WS_NEXT_ERROR, abort");
28672884
ret = WS_SCP_ABORT;
28682885
}
@@ -2948,6 +2965,20 @@ static int ScpProcessEntry(WOLFSSH* ssh, char* fileName, word64* mTime,
29482965
* is complete.
29492966
* WS_SCP_ABORT - abort file transfer request
29502967
* WS_BAD_FILE_E - local file open error hit
2968+
*
2969+
* Symlink handling: file-content opens go through wFopenNoFollow, atomic
2970+
* against a swapped link on POSIX (O_NOFOLLOW) and falling back to a wIsSymlink
2971+
* check-then-open on Windows or where O_NOFOLLOW is absent. The recursive root
2972+
* is opened with opendir (which follows links), so it is leaf-checked with
2973+
* wIsSymlink instead; symlinks below the root are rejected as ScpProcessEntry
2974+
* traverses them. No "stays under a trusted base" containment is attempted:
2975+
* SCP has no library-level base path (wolfsshd relies on OS chroot) and
2976+
* wolfSSH_RealPath does not resolve links. The metadata WSTAT in GetFileStats
2977+
* still follows links and the Windows/fallback open is check-then-open, so
2978+
* those remain best-effort: a concurrent in-jail writer running as the server
2979+
* user could race the metadata stat. For hostile multi-tenant use, confine the
2980+
* session with an OS mechanism (chroot, dropped privileges) and treat these
2981+
* checks as defense in depth.
29512982
*/
29522983
int wsScpSendCallback(WOLFSSH* ssh, int state, const char* peerRequest,
29532984
char* fileName, word32 fileNameSz, word64* mTime, word64* aTime,
@@ -2981,9 +3012,14 @@ int wsScpSendCallback(WOLFSSH* ssh, int state, const char* peerRequest,
29813012
break;
29823013

29833014
case WOLFSSH_SCP_SINGLE_FILE_REQUEST:
2984-
if ((sendCtx == NULL) || WFOPEN(ssh->fs, &(sendCtx->fp), peerRequest,
2985-
"rb") != 0) {
2986-
3015+
/* open without following a symlink so its target is not streamed
3016+
* to the peer; see this function's symlink-handling note. */
3017+
if ((sendCtx == NULL) ||
3018+
#ifdef WOLFSSH_HAVE_SYMLINK
3019+
wFopenNoFollow(&(sendCtx->fp), peerRequest) != 0) {
3020+
#else
3021+
WFOPEN(ssh->fs, &(sendCtx->fp), peerRequest, "rb") != 0) {
3022+
#endif
29873023
WLOG(WS_LOG_ERROR, "scp: unable to open file, abort");
29883024
wolfSSH_SetScpErrorMsg(ssh, "unable to open file for reading");
29893025
ret = WS_BAD_FILE_E;
@@ -3038,8 +3074,23 @@ int wsScpSendCallback(WOLFSSH* ssh, int state, const char* peerRequest,
30383074

30393075
if (ScpDirStackIsEmpty(sendCtx)) {
30403076

3041-
/* first request, keep track of request directory */
3042-
ret = ScpPushDir(ssh->fs, sendCtx, peerRequest, ssh->ctx->heap);
3077+
/* first request, keep track of request directory. Reject a
3078+
* symlink root here (opendir would follow it); see the
3079+
* symlink-handling note in this function's header. */
3080+
ret = WS_SUCCESS;
3081+
#ifdef WOLFSSH_HAVE_SYMLINK
3082+
if (wIsSymlink(peerRequest)) {
3083+
WLOG(WS_LOG_ERROR,
3084+
"scp: refusing recursive root symlink, abort");
3085+
wolfSSH_SetScpErrorMsg(ssh,
3086+
"unable to open file for reading");
3087+
ret = WS_SCP_ABORT;
3088+
}
3089+
#endif /* WOLFSSH_HAVE_SYMLINK */
3090+
3091+
if (ret == WS_SUCCESS)
3092+
ret = ScpPushDir(ssh->fs, sendCtx, peerRequest,
3093+
ssh->ctx->heap);
30433094

30443095
if (ret == WS_SUCCESS) {
30453096
/* get file name from request */
@@ -3053,7 +3104,9 @@ int wsScpSendCallback(WOLFSSH* ssh, int state, const char* peerRequest,
30533104

30543105
if (ret == WS_SUCCESS) {
30553106
ret = WS_SCP_ENTER_DIR;
3056-
} else {
3107+
} else if (ret != WS_SCP_ABORT) {
3108+
/* a rejected symlink root already logged its own reason;
3109+
* only note the generic stat failure here */
30573110
WLOG(WS_LOG_ERROR, "scp: error getting file stats, abort");
30583111
ret = WS_SCP_ABORT;
30593112
}

tests/api.c

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,13 @@
4040
#include <stdlib.h>
4141
#include <unistd.h>
4242
#endif
43+
#if defined(WOLFSSH_SCP) && defined(WOLFSSH_HAVE_SYMLINK) && \
44+
!defined(SINGLE_THREADED) && !defined(WOLFSSH_ZEPHYR) && \
45+
!defined(USE_WINDOWS_API)
46+
/* mkdtemp()/symlink() for the SCP send symlink-rejection test */
47+
#include <stdlib.h>
48+
#include <unistd.h>
49+
#endif
4350
#include <wolfssh/ssh.h>
4451
#include <wolfssh/internal.h>
4552
#include <wolfssh/log.h>
@@ -1045,6 +1052,88 @@ static void test_wolfSSH_SCP_CB(void)
10451052
static void test_wolfSSH_SCP_CB(void) { ; }
10461053
#endif /* WOLFSSH_SCP */
10471054

1055+
#if defined(WOLFSSH_SCP) && defined(WOLFSSH_HAVE_SYMLINK) && \
1056+
!defined(SINGLE_THREADED) && !defined(WOLFSSH_ZEPHYR) && \
1057+
!defined(USE_WINDOWS_API)
1058+
/* The default SCP send callback must refuse a symlink so its target is not
1059+
* streamed to the peer. The example SCP client cannot issue a recursive
1060+
* ("scp -f -r") request, so the recursive-root guard in wsScpSendCallback is
1061+
* unreachable through scripts/scp.test; drive it here at the callback level
1062+
* with a planted symlink directory. wFopenNoFollow, shared by the single-file
1063+
* and recursive file opens, is exercised directly for both the accept (real
1064+
* file) and reject (symlink) cases. */
1065+
static void test_wolfSSH_SCP_SendSymlinkReject(void)
1066+
{
1067+
WOLFSSH_CTX* ctx = NULL;
1068+
WOLFSSH* ssh = NULL;
1069+
ScpSendCtx sendCtx;
1070+
WFILE* fp = NULL;
1071+
char scpRoot[] = "/tmp/wolfssh_scp_sym_XXXXXX";
1072+
char realFile[WOLFSSH_MAX_FILENAME];
1073+
char symToFile[WOLFSSH_MAX_FILENAME];
1074+
char symToDir[WOLFSSH_MAX_FILENAME];
1075+
char fileName[DEFAULT_SCP_FILE_NAME_SZ];
1076+
byte buf[256];
1077+
word64 mTime = 0;
1078+
word64 aTime = 0;
1079+
int fileMode = 0;
1080+
word32 totalSz = 0;
1081+
1082+
AssertNotNull(mkdtemp(scpRoot));
1083+
1084+
WSNPRINTF(realFile, sizeof(realFile), "%s/secret", scpRoot);
1085+
WSNPRINTF(symToFile, sizeof(symToFile), "%s/link_file", scpRoot);
1086+
WSNPRINTF(symToDir, sizeof(symToDir), "%s/link_dir", scpRoot);
1087+
1088+
/* stage an empty real file plus a symlink to it and to the temp dir */
1089+
AssertIntEQ(WFOPEN(NULL, &fp, realFile, "wb"), 0);
1090+
WFCLOSE(NULL, fp);
1091+
fp = NULL;
1092+
AssertIntEQ(symlink(realFile, symToFile), 0);
1093+
AssertIntEQ(symlink(scpRoot, symToDir), 0);
1094+
1095+
/* the guard's discriminator: a real path is not a link, the planted ones
1096+
* are - so a genuine directory would not trip the recursive-root check */
1097+
AssertIntEQ(wIsSymlink(scpRoot), 0);
1098+
AssertIntEQ(wIsSymlink(symToDir), 1);
1099+
1100+
/* wFopenNoFollow opens a real file but refuses a symlink */
1101+
AssertIntEQ(wFopenNoFollow(&fp, realFile), 0);
1102+
AssertNotNull(fp);
1103+
WFCLOSE(NULL, fp);
1104+
fp = NULL;
1105+
AssertIntNE(wFopenNoFollow(&fp, symToFile), 0);
1106+
AssertNull(fp);
1107+
1108+
AssertNotNull(ctx = wolfSSH_CTX_new(WOLFSSH_ENDPOINT_SERVER, NULL));
1109+
AssertNotNull(ssh = wolfSSH_new(ctx));
1110+
WMEMSET(&sendCtx, 0, sizeof(sendCtx));
1111+
WMEMSET(fileName, 0, sizeof(fileName));
1112+
1113+
/* a benign state returns success (the callback does not blanket-abort) */
1114+
AssertIntEQ(wsScpSendCallback(ssh, WOLFSSH_SCP_NEW_REQUEST, symToDir,
1115+
fileName, (word32)sizeof(fileName), &mTime, &aTime, &fileMode, 0,
1116+
&totalSz, buf, (word32)sizeof(buf), &sendCtx), WS_SUCCESS);
1117+
1118+
/* a recursive root that is a symlink aborts before any directory is
1119+
* opened, so nothing is pushed onto the stack and nothing leaks */
1120+
AssertIntEQ(wsScpSendCallback(ssh, WOLFSSH_SCP_RECURSIVE_REQUEST, symToDir,
1121+
fileName, (word32)sizeof(fileName), &mTime, &aTime, &fileMode, 0,
1122+
&totalSz, buf, (word32)sizeof(buf), &sendCtx), WS_SCP_ABORT);
1123+
AssertNull(sendCtx.currentDir);
1124+
1125+
wolfSSH_free(ssh);
1126+
wolfSSH_CTX_free(ctx);
1127+
1128+
WREMOVE(NULL, symToFile);
1129+
WREMOVE(NULL, symToDir);
1130+
WREMOVE(NULL, realFile);
1131+
WRMDIR(NULL, scpRoot);
1132+
}
1133+
#else
1134+
static void test_wolfSSH_SCP_SendSymlinkReject(void) { ; }
1135+
#endif
1136+
10481137
#ifdef WOLFSSH_AGENT
10491138
typedef struct AgentTestCtx {
10501139
int partialWrite;
@@ -3610,6 +3699,7 @@ int wolfSSH_ApiTest(int argc, char** argv)
36103699

36113700
/* SCP tests */
36123701
test_wolfSSH_SCP_CB();
3702+
test_wolfSSH_SCP_SendSymlinkReject();
36133703
test_wolfSSH_SCP_ReKey();
36143704
test_wolfSSH_SCP_ReKey_NonBlock();
36153705
test_wolfSSH_SCP_ReKey_ToServer();

wolfssh/port.h

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1325,6 +1325,7 @@ extern "C" {
13251325
#define WGETCWD(fs,r,rSz) _getcwd((r),(rSz))
13261326
#define WOPEN(fs,f,m,p) _open((f),(m),(p))
13271327
#define WCLOSE(fs,fd) _close((fd))
1328+
#define WFDOPEN(fs,f,fd,m) ((*(f) = _fdopen((fd),(m))) == NULL)
13281329

13291330
#define WFD int
13301331
int wPwrite(WFD fd, unsigned char* buf, unsigned int sz,
@@ -1495,6 +1496,7 @@ extern "C" {
14951496

14961497
#define WOPEN(fs,f,m,p) open((f),(m),(p))
14971498
#define WCLOSE(fs,fd) close((fd))
1499+
#define WFDOPEN(fs,f,fd,m) ((*(f) = fdopen((fd),(m))) == NULL)
14981500
int wPwrite(WFD fd, unsigned char* buf, unsigned int sz,
14991501
const unsigned int* shortOffset);
15001502
int wPread(WFD fd, unsigned char* buf, unsigned int sz,
@@ -1534,10 +1536,27 @@ extern "C" {
15341536
/* wIsSymlink lives in the always-compiled port.c, but its filesystem
15351537
* dependencies (WSTAT_T/WLSTAT/S_ISLNK on POSIX, WS_GetFileAttributesExA on
15361538
* 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. */
1539+
* gate it on those features in addition to the platform capability. Shared by
1540+
* the SFTP path-confinement guard and the SCP send guards. */
15381541
#if defined(WOLFSSH_HAVE_SYMLINK) && \
15391542
(defined(WOLFSSH_SFTP) || defined(WOLFSSH_SCP))
15401543
WOLFSSH_LOCAL int wIsSymlink(const char* path);
1544+
1545+
/* Open flag that refuses to follow a final-component symbolic link, so the
1546+
* open is atomic against a swapped link. Maps to the platform primitive
1547+
* where available (POSIX O_NOFOLLOW) and to 0 elsewhere (Windows and any
1548+
* filesystem lacking it), where wFopenNoFollow falls back to a wIsSymlink
1549+
* check before the open. */
1550+
#ifdef O_NOFOLLOW
1551+
#define WOLFSSH_O_NOFOLLOW O_NOFOLLOW
1552+
#else
1553+
#define WOLFSSH_O_NOFOLLOW 0
1554+
#endif
1555+
1556+
/* Open a file for reading without following a final-component symlink.
1557+
* Returns 0 on success, non-zero on failure, matching the wfopen
1558+
* convention. Shared, un-followed read-open used by the SCP send guards. */
1559+
WOLFSSH_LOCAL int wFopenNoFollow(WFILE** f, const char* path);
15411560
#endif
15421561

15431562
#ifndef WS_MAYBE_UNUSED

0 commit comments

Comments
 (0)